Compare commits

..

3 Commits

Author SHA1 Message Date
bwees faaeaace1d fix: memory viewer bar 2026-05-22 16:50:18 -05:00
bwees 2837de2029 chore: ci fix 2026-05-22 14:01:11 -05:00
bwees eb27635f22 refactor: use ControlBar UI Library component 2026-05-22 11:35:16 -05:00
47 changed files with 386 additions and 2292 deletions
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
force: false, force: false,
ids: [assetToTrash.id], ids: [assetToTrash.id],
}); });
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByText('Trash', { exact: true }).click(); await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id); await thumbnailUtils.expectInViewport(page, assetToTrash.id);
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
ids: [assetToArchive.id], ids: [assetToArchive.id],
}); });
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id); await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Archive').click(); await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToArchive.id); await thumbnailUtils.expectInViewport(page, assetToArchive.id);
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
}); });
// ensure thumbnail still exists and has favorite icon // ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id); await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click(); await page.keyboard.press('Escape');
await page.getByRole('link').getByText('Favorites').click(); await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page); await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt); await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
@@ -89,20 +89,6 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
<!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="image/*" />
</intent-filter>
<!-- Allow Immich to act as a video viewer -->
<intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling --> <!-- immich:// URL scheme handling -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -1,7 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock import app.alextran.immich.background.BackgroundEngineLock
@@ -23,7 +22,6 @@ import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30 import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -33,11 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
registerPlugins(this, flutterEngine) registerPlugins(this, flutterEngine)
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
companion object { companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx) HttpClientManager.initialize(ctx)
@@ -62,7 +55,6 @@ class MainActivity : FlutterFragmentActivity() {
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl) flutterEngine.plugins.add(permissionApiImpl)
@@ -1,292 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.viewintent
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object ViewIntentPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
/** Generated class from Pigeon that represents data sent in messages. */
data class ViewIntentPayload (
val path: String? = null,
val mimeType: String,
val localAssetId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
val path = pigeonVar_list[0] as String?
val mimeType = pigeonVar_list[1] as String
val localAssetId = pigeonVar_list[2] as String?
return ViewIntentPayload(path, mimeType, localAssetId)
}
}
fun toList(): List<Any?> {
return listOf(
path,
mimeType,
localAssetId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ViewIntentPayload
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
return result
}
}
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ViewIntentPayload.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ViewIntentPayload -> {
stream.write(129)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ViewIntentHostApi {
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
companion object {
/** The codec used by ViewIntentHostApi. */
val codec: MessageCodec<Any?> by lazy {
ViewIntentPigeonCodec()
}
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ViewIntentPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -1,201 +0,0 @@
package app.alextran.immich.viewintent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
private const val TAG = "ViewIntentPlugin"
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
private var context: Context? = null
private var activity: Activity? = null
private var unconsumedIntent: Intent? = null
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
ioScope.cancel()
context = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
unconsumedIntent = binding.activity.intent
binding.addOnNewIntentListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onNewIntent(intent: Intent): Boolean {
unconsumedIntent = intent
return false
}
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
val context = context ?: run {
callback(Result.success(null))
return
}
val intent = unconsumedIntent ?: activity?.intent
if (intent?.action != Intent.ACTION_VIEW) {
callback(Result.success(null))
return
}
val uri = intent.data
if (uri == null) {
callback(Result.success(null))
return
}
ioScope.launch {
try {
val mimeType = context.contentResolver.getType(uri) ?: intent.type
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
callback(Result.success(null))
return@launch
}
val localAssetId = extractLocalAssetId(context, uri, mimeType)
val tempFilePath = if (localAssetId == null) {
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
callback(Result.success(null))
return@launch
}
} else {
null
}
val payload = ViewIntentPayload(
path = tempFilePath,
mimeType = mimeType,
localAssetId = localAssetId,
)
consumeViewIntent(intent)
callback(Result.success(payload))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
private fun consumeViewIntent(currentIntent: Intent) {
unconsumedIntent = Intent(currentIntent).apply {
action = null
data = null
type = null
}
activity?.intent = unconsumedIntent
}
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
return tryExtractDocumentLocalAssetId(context, uri)
?: tryParseContentUriId(uri)
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
}
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
return try {
if (!DocumentsContract.isDocumentUri(context, uri)) return null
val docId = DocumentsContract.getDocumentId(uri)
if (docId.isBlank() || docId.startsWith("raw:")) return null
docId.substringAfter(':', docId).toLongOrNull()?.toString()
} catch (e: Exception) {
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
null
}
}
private fun tryParseContentUriId(uri: Uri): String? {
val id = uri.lastPathSegment?.toLongOrNull() ?: return null
return if (id >= 0) id.toString() else null
}
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
return try {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
tempFile
} catch (_: Exception) {
null
}
}
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val (displayName, size) =
try {
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
if (name.isNullOrBlank() || bytes < 0) return null
name to bytes
} ?: return null
} catch (_: Exception) {
return null
}
val tableUri = when {
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> return null
}
return try {
context.contentResolver
.query(
tableUri,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
arrayOf(displayName, size.toString()),
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
)?.use { cursor ->
if (!cursor.moveToFirst()) return null
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
if (idIndex < 0) return null
cursor.getLong(idIndex).toString()
}
} catch (_: Exception) {
null
}
}
}
-3
View File
@@ -24,7 +24,6 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
@@ -129,7 +128,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
dPrint(() => "[APP STATE] resumed"); dPrint(() => "[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume(); ref.read(appStateProvider.notifier).handleAppResume();
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
break; break;
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
dPrint(() => "[APP STATE] inactive"); dPrint(() => "[APP STATE] inactive");
@@ -235,7 +233,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
} }
}); });
ref.read(viewIntentHandlerProvider).init();
ref.read(shareIntentUploadProvider.notifier).init(); ref.read(shareIntentUploadProvider.notifier).init();
} }
@@ -1,35 +0,0 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart';
extension ViewIntentPayloadX on ViewIntentPayload {
String get fileName {
final resolvedPath = path;
if (resolvedPath != null && resolvedPath.isNotEmpty) {
return basename(resolvedPath);
}
return localAssetId ?? 'view_intent_asset';
}
bool get isImage => mimeType.toLowerCase().startsWith('image/');
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
final normalizedMimeType = mimeType.toLowerCase();
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
return AssetPlaybackStyle.imageAnimated;
}
final normalizedPath = path?.toLowerCase();
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
return AssetPlaybackStyle.imageAnimated;
}
return AssetPlaybackStyle.image;
}
}
@@ -17,7 +17,6 @@ import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart'; import 'package:immich_mobile/theme/color_scheme.dart';
@@ -315,7 +314,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier); final backupProvider = ref.read(driftBackupProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
unawaited( unawaited(
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
@@ -330,8 +328,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
backgroundManager.syncRemote().then((success) => syncSuccess = success), backgroundManager.syncRemote().then((success) => syncSuccess = success),
]); ]);
await viewIntentHandler.flushDeferredViewIntent();
if (syncSuccess) { if (syncSuccess) {
await Future.wait([ await Future.wait([
backgroundManager.hashAssets().then((_) { backgroundManager.hashAssets().then((_) {
+35 -35
View File
@@ -9,22 +9,14 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow( Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) { if (replyList == null) {
throw PlatformException( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".', message: 'Unable to establish connection on channel: "$channelName".',
); );
} else if (replyList.length > 1) { } else if (replyList.length > 1) {
throw PlatformException( throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
code: replyList[0]! as String,
message: replyList[1] as String?,
details: replyList[2],
);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException( throw PlatformException(
code: 'null-error', code: 'null-error',
@@ -34,8 +26,6 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull; return replyList.firstOrNull;
} }
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -62,35 +52,50 @@ class LocalImageApi {
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage(String assetId, {required int requestId, required int width, required int height, required bool isVideo, required bool preferEncoded, }) async { Future<Map<String, int>?> requestImage(
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height, isVideo, preferEncoded]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
preferEncoded,
]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: true, isNullValid: true,
) );
;
return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>(); return (pigeonVar_replyValue as Map<Object?, Object?>?)?.cast<String, int>();
} }
Future<void> cancelRequest(int requestId) async { Future<void> cancelRequest(int requestId) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -99,16 +104,12 @@ class LocalImageApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[requestId]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow( _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
} }
Future<Map<String, int>> getThumbhash(String thumbhash) async { Future<Map<String, int>> getThumbhash(String thumbhash) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -118,11 +119,10 @@ class LocalImageApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, int>(); return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, int>();
} }
} }
+126 -170
View File
@@ -9,22 +9,14 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow( Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) { if (replyList == null) {
throw PlatformException( throw PlatformException(
code: 'channel-error', code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".', message: 'Unable to establish connection on channel: "$channelName".',
); );
} else if (replyList.length > 1) { } else if (replyList.length > 1) {
throw PlatformException( throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
code: replyList[0]! as String,
message: replyList[1] as String?,
details: replyList[2],
);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException( throw PlatformException(
code: 'null-error', code: 'null-error',
@@ -45,9 +37,7 @@ bool _deepEquals(Object? a, Object? b) {
return a == b; return a == b;
} }
if (a is List && b is List) { if (a is List && b is List) {
return a.length == b.length && return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
} }
if (a is Map && b is Map) { if (a is Map && b is Map) {
if (a.length != b.length) { if (a.length != b.length) {
@@ -96,15 +86,7 @@ int _deepHash(Object? value) {
return value.hashCode; return value.hashCode;
} }
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset { class PlatformAsset {
PlatformAsset({ PlatformAsset({
@@ -172,7 +154,8 @@ class PlatformAsset {
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static PlatformAsset decode(Object result) { static PlatformAsset decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -203,7 +186,20 @@ class PlatformAsset {
if (identical(this, other)) { if (identical(this, other)) {
return true; return true;
} }
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle); return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(type, other.type) &&
_deepEquals(createdAt, other.createdAt) &&
_deepEquals(updatedAt, other.updatedAt) &&
_deepEquals(width, other.width) &&
_deepEquals(height, other.height) &&
_deepEquals(durationMs, other.durationMs) &&
_deepEquals(orientation, other.orientation) &&
_deepEquals(isFavorite, other.isFavorite) &&
_deepEquals(adjustmentTime, other.adjustmentTime) &&
_deepEquals(latitude, other.latitude) &&
_deepEquals(longitude, other.longitude) &&
_deepEquals(playbackStyle, other.playbackStyle);
} }
@override @override
@@ -231,17 +227,12 @@ class PlatformAlbum {
int assetCount; int assetCount;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[id, name, updatedAt, isCloud, assetCount];
id,
name,
updatedAt,
isCloud,
assetCount,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static PlatformAlbum decode(Object result) { static PlatformAlbum decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -263,7 +254,11 @@ class PlatformAlbum {
if (identical(this, other)) { if (identical(this, other)) {
return true; return true;
} }
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount); return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(updatedAt, other.updatedAt) &&
_deepEquals(isCloud, other.isCloud) &&
_deepEquals(assetCount, other.assetCount);
} }
@override @override
@@ -272,12 +267,7 @@ class PlatformAlbum {
} }
class SyncDelta { class SyncDelta {
SyncDelta({ SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
required this.hasChanges,
required this.updates,
required this.deletes,
required this.assetAlbums,
});
bool hasChanges; bool hasChanges;
@@ -288,16 +278,12 @@ class SyncDelta {
Map<String, List<String>> assetAlbums; Map<String, List<String>> assetAlbums;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[hasChanges, updates, deletes, assetAlbums];
hasChanges,
updates,
deletes,
assetAlbums,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static SyncDelta decode(Object result) { static SyncDelta decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -318,7 +304,10 @@ class SyncDelta {
if (identical(this, other)) { if (identical(this, other)) {
return true; return true;
} }
return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums); return _deepEquals(hasChanges, other.hasChanges) &&
_deepEquals(updates, other.updates) &&
_deepEquals(deletes, other.deletes) &&
_deepEquals(assetAlbums, other.assetAlbums);
} }
@override @override
@@ -327,11 +316,7 @@ class SyncDelta {
} }
class HashResult { class HashResult {
HashResult({ HashResult({required this.assetId, this.error, this.hash});
required this.assetId,
this.error,
this.hash,
});
String assetId; String assetId;
@@ -340,23 +325,16 @@ class HashResult {
String? hash; String? hash;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[assetId, error, hash];
assetId,
error,
hash,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static HashResult decode(Object result) { static HashResult decode(Object result) {
result as List<Object?>; result as List<Object?>;
return HashResult( return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
assetId: result[0]! as String,
error: result[1] as String?,
hash: result[2] as String?,
);
} }
@override @override
@@ -377,11 +355,7 @@ class HashResult {
} }
class CloudIdResult { class CloudIdResult {
CloudIdResult({ CloudIdResult({required this.assetId, this.error, this.cloudId});
required this.assetId,
this.error,
this.cloudId,
});
String assetId; String assetId;
@@ -390,23 +364,16 @@ class CloudIdResult {
String? cloudId; String? cloudId;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[assetId, error, cloudId];
assetId,
error,
cloudId,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static CloudIdResult decode(Object result) { static CloudIdResult decode(Object result) {
result as List<Object?>; result as List<Object?>;
return CloudIdResult( return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
assetId: result[0]! as String,
error: result[1] as String?,
cloudId: result[2] as String?,
);
} }
@override @override
@@ -418,7 +385,9 @@ class CloudIdResult {
if (identical(this, other)) { if (identical(this, other)) {
return true; return true;
} }
return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId); return _deepEquals(assetId, other.assetId) &&
_deepEquals(error, other.error) &&
_deepEquals(cloudId, other.cloudId);
} }
@override @override
@@ -426,7 +395,6 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]); int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
} }
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -434,22 +402,22 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is PlatformAssetPlaybackStyle) { } else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is PlatformAsset) { } else if (value is PlatformAsset) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) { } else if (value is PlatformAlbum) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is SyncDelta) { } else if (value is SyncDelta) {
buffer.putUint8(132); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is HashResult) { } else if (value is HashResult) {
buffer.putUint8(133); buffer.putUint8(133);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is CloudIdResult) { } else if (value is CloudIdResult) {
buffer.putUint8(134); buffer.putUint8(134);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
@@ -484,8 +452,8 @@ class NativeSyncApi {
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -493,7 +461,8 @@ class NativeSyncApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<bool> shouldFullSync() async { Future<bool> shouldFullSync() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -503,16 +472,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return pigeonVar_replyValue! as bool; return pigeonVar_replyValue! as bool;
} }
Future<SyncDelta> getMediaChanges() async { Future<SyncDelta> getMediaChanges() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -522,16 +491,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return pigeonVar_replyValue! as SyncDelta; return pigeonVar_replyValue! as SyncDelta;
} }
Future<void> checkpointSync() async { Future<void> checkpointSync() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -540,16 +509,12 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow( _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
} }
Future<void> clearSyncCheckpoint() async { Future<void> clearSyncCheckpoint() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -558,16 +523,12 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow( _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
} }
Future<List<String>> getAssetIdsForAlbum(String albumId) async { Future<List<String>> getAssetIdsForAlbum(String albumId) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -577,16 +538,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as List<Object?>).cast<String>(); return (pigeonVar_replyValue! as List<Object?>).cast<String>();
} }
Future<List<PlatformAlbum>> getAlbums() async { Future<List<PlatformAlbum>> getAlbums() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -596,16 +557,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>(); return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
} }
Future<int> getAssetsCountSince(String albumId, int timestamp) async { Future<int> getAssetsCountSince(String albumId, int timestamp) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -615,16 +576,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return pigeonVar_replyValue! as int; return pigeonVar_replyValue! as int;
} }
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async { Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -634,16 +595,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>(); return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
} }
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async { Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -653,16 +614,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>(); return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
} }
Future<void> cancelHashing() async { Future<void> cancelHashing() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -671,16 +632,12 @@ class NativeSyncApi {
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow( _extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
} }
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async { Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -690,16 +647,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>(); return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
} }
Future<bool> restoreFromTrashById(String mediaId, int type) async { Future<bool> restoreFromTrashById(String mediaId, int type) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -709,16 +666,16 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return pigeonVar_replyValue! as bool; return pigeonVar_replyValue! as bool;
} }
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async { Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -728,11 +685,10 @@ class NativeSyncApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList, pigeonVar_replyList,
pigeonVar_channelName, pigeonVar_channelName,
isNullValid: false, isNullValid: false,
) );
;
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>(); return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
} }
} }
-208
View File
@@ -1,208 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(
code: replyList[0]! as String,
message: replyList[1] as String?,
details: replyList[2],
);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
}
if (a is double && b is double) {
if (a.isNaN && b.isNaN) {
return true;
}
return a == b;
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
return false;
}
for (final MapEntry<Object?, Object?> entryA in a.entries) {
bool found = false;
for (final MapEntry<Object?, Object?> entryB in b.entries) {
if (_deepEquals(entryA.key, entryB.key)) {
if (_deepEquals(entryA.value, entryB.value)) {
found = true;
break;
} else {
return false;
}
}
}
if (!found) {
return false;
}
}
return true;
}
return a == b;
}
int _deepHash(Object? value) {
if (value is List) {
return Object.hashAll(value.map(_deepHash));
}
if (value is Map) {
int result = 0;
for (final MapEntry<Object?, Object?> entry in value.entries) {
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
}
return result;
}
if (value is double && value.isNaN) {
// Normalize NaN to a consistent hash.
return 0x7FF8000000000000.hashCode;
}
if (value is double && value == 0.0) {
// Normalize -0.0 to 0.0 so they have the same hash code.
return 0.0.hashCode;
}
return value.hashCode;
}
class ViewIntentPayload {
ViewIntentPayload({
this.path,
required this.mimeType,
this.localAssetId,
});
String? path;
String mimeType;
String? localAssetId;
List<Object?> _toList() {
return <Object?>[
path,
mimeType,
localAssetId,
];
}
Object encode() {
return _toList(); }
static ViewIntentPayload decode(Object result) {
result as List<Object?>;
return ViewIntentPayload(
path: result[0] as String?,
mimeType: result[1]! as String,
localAssetId: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) && _deepEquals(mimeType, other.mimeType) && _deepEquals(localAssetId, other.localAssetId);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is ViewIntentPayload) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return ViewIntentPayload.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class ViewIntentHostApi {
/// Constructor for [ViewIntentHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<ViewIntentPayload?> consumeViewIntent() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as ViewIntentPayload?;
}
}
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@@ -11,9 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart'; import 'package:immich_ui/immich_ui.dart';
@@ -30,11 +26,7 @@ class UploadActionButton extends ConsumerWidget {
} }
final isTimeline = source == ActionSource.timeline; final isTimeline = source == ActionSource.timeline;
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
List<LocalAsset>? assets; List<LocalAsset>? assets;
var isUploadDialogOpen = false;
var wasUploadCancelled = false;
Future<void>? uploadDialogFuture;
if (source == ActionSource.timeline) { if (source == ActionSource.timeline) {
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList(); assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
@@ -43,50 +35,22 @@ class UploadActionButton extends ConsumerWidget {
} }
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
} else { } else {
isUploadDialogOpen = true; unawaited(
uploadDialogFuture = showDialog(
showDialog<void>( context: context,
context: context, barrierDismissible: false,
barrierDismissible: false, builder: (dialogContext) => const _UploadProgressDialog(),
builder: (dialogContext) => _UploadProgressDialog( ),
onCancel: () { );
wasUploadCancelled = true;
},
),
).whenComplete(() {
isUploadDialogOpen = false;
});
unawaited(uploadDialogFuture);
} }
var success = false; final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
if (!isTimeline && viewerIntentFilePath != null) {
final viewIntentService = ref.read(viewIntentServiceProvider);
viewIntentService.markUploadActive(viewerIntentFilePath);
var hasError = false;
try {
await ref
.read(foregroundUploadServiceProvider)
.uploadShareIntent(
[File(viewerIntentFilePath)],
onError: (_, _) {
hasError = true;
},
);
} finally {
await viewIntentService.markUploadInactive(viewerIntentFilePath);
}
success = !hasError;
} else {
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
success = result.success;
}
if (!isTimeline && context.mounted && isUploadDialogOpen) { if (!isTimeline && context.mounted) {
Navigator.of(context, rootNavigator: true).pop(); Navigator.of(context, rootNavigator: true).pop();
} }
if (context.mounted && !success && !wasUploadCancelled) { if (context.mounted && !result.success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'scaffold_body_error_occurred'.t(context: context), msg: 'scaffold_body_error_occurred'.t(context: context),
@@ -109,9 +73,7 @@ class UploadActionButton extends ConsumerWidget {
} }
class _UploadProgressDialog extends ConsumerWidget { class _UploadProgressDialog extends ConsumerWidget {
final VoidCallback onCancel; const _UploadProgressDialog();
const _UploadProgressDialog({required this.onCancel});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -141,8 +103,7 @@ class _UploadProgressDialog extends ConsumerWidget {
onPressed: () { onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.complete(); ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null; ref.read(manualUploadCancelTokenProvider.notifier).state = null;
onCancel(); Navigator.of(context).pop();
Navigator.of(context, rootNavigator: true).pop();
}, },
labelText: 'cancel'.t(context: context), labelText: 'cancel'.t(context: context),
), ),
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -324,16 +323,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required PhotoViewHeroAttributes? heroAttributes, required PhotoViewHeroAttributes? heroAttributes,
required bool isCurrent, required bool isCurrent,
required bool isPlayingMotionVideo, required bool isPlayingMotionVideo,
required String? localFilePath,
}) { }) {
final size = context.sizeData; final size = context.sizeData;
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
if (asset.isImage && !isPlayingMotionVideo) { if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView( return PhotoView(
key: Key(asset.heroTag), key: Key(asset.heroTag),
index: widget.index, index: widget.index,
imageProvider: imageProvider, imageProvider: getFullImageProvider(asset, size: size),
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
gaplessPlayback: true, gaplessPlayback: true,
@@ -380,9 +377,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(asset.heroTag), key: _NativeVideoViewerKey(asset.heroTag),
asset: asset, asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent, isCurrent: isCurrent,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), image: Image(
image: getFullImageProvider(asset, size: size),
fit: BoxFit.contain,
alignment: Alignment.center,
),
), ),
); );
} }
@@ -393,7 +393,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final asset = _asset; final asset = _asset;
if (asset == null) { if (asset == null) {
@@ -422,8 +421,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_scrollController.snapPosition.snapOffset = _snapOffset; _scrollController.snapPosition.snapOffset = _snapOffset;
} }
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
return Stack( return Stack(
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
@@ -443,7 +440,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
: null, : null,
isCurrent: isCurrent, isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo, isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
), ),
), ),
IgnorePointer( IgnorePointer(
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -20,7 +19,6 @@ import 'package:native_video_player/native_video_player.dart';
class NativeVideoViewer extends ConsumerStatefulWidget { class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset; final BaseAsset asset;
final String? localFilePath;
final bool isCurrent; final bool isCurrent;
final bool showControls; final bool showControls;
final Widget image; final Widget image;
@@ -28,7 +26,6 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
const NativeVideoViewer({ const NativeVideoViewer({
super.key, super.key,
required this.asset, required this.asset,
this.localFilePath,
required this.image, required this.image,
this.isCurrent = false, this.isCurrent = false,
this.showControls = true, this.showControls = true,
@@ -109,19 +106,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
} }
try { try {
final localFilePath = widget.localFilePath;
if (localFilePath != null) {
final file = File(localFilePath);
if (!await file.exists()) {
throw Exception('No file found for the video');
}
return VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id); final file = await StorageRepository().getFileForAsset(id);
@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:async/async.dart'; import 'package:async/async.dart';
@@ -147,17 +146,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
} }
} }
ImageProvider getFullImageProvider( ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
BaseAsset asset, {
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
}) {
// Create new provider and cache it // Create new provider and cache it
final ImageProvider provider; final ImageProvider provider;
if (localFilePath != null) { if (_shouldUseLocalAsset(asset)) {
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
} else { } else {
@@ -3,8 +3,8 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -70,7 +70,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
_updateProgress(fileId, progress); _updateProgress(fileId, progress);
}, },
onSuccess: (fileId, _) { onSuccess: (fileId) {
_updateStatus(fileId, UploadStatus.complete, progress: 1.0); _updateStatus(fileId, UploadStatus.complete, progress: 1.0);
}, },
onError: (fileId, errorMessage) { onError: (fileId, errorMessage) {
@@ -31,12 +31,11 @@ class ActionResult {
final int count; final int count;
final bool success; final bool success;
final String? error; final String? error;
final List<String> remoteAssetIds;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []}); const ActionResult({required this.count, required this.success, this.error});
@override @override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)'; String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
} }
class ActionNotifier extends Notifier<void> { class ActionNotifier extends Notifier<void> {
@@ -490,14 +489,10 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async { Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList(); final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
if (assetsToUpload.isEmpty) {
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>(); final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
final remoteAssetIds = <String>[];
// Initialize progress for all assets // Initialize progress for all assets
for (final asset in assetsToUpload) { for (final asset in assetsToUpload) {
@@ -514,7 +509,6 @@ class ActionNotifier extends Notifier<void> {
progressNotifier.setProgress(localAssetId, progress); progressNotifier.setProgress(localAssetId, progress);
}, },
onSuccess: (localAssetId, remoteAssetId) { onSuccess: (localAssetId, remoteAssetId) {
remoteAssetIds.add(remoteAssetId);
progressNotifier.remove(localAssetId); progressNotifier.remove(localAssetId);
}, },
onError: (localAssetId, errorMessage) { onError: (localAssetId, errorMessage) {
@@ -522,14 +516,7 @@ class ActionNotifier extends Notifier<void> {
}, },
), ),
); );
final uploadedCount = remoteAssetIds.length; return ActionResult(count: assetsToUpload.length, success: true);
final success = uploadedCount == assetsToUpload.length;
return ActionResult(
count: assetsToUpload.length,
success: success,
error: success ? null : 'Uploaded $uploadedCount/${assetsToUpload.length} assets successfully',
remoteAssetIds: remoteAssetIds,
);
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack); _logger.severe('Failed manually upload assets', error, stack);
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString()); return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
@@ -1,31 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ViewIntentFilePathNotifier extends Notifier<String?> {
@override
String? build() => null;
void setPath(String path) {
if (state == path) {
return;
}
state = path;
}
void clear() {
if (state == null) {
return;
}
state = null;
}
void clearIfMatch(String path) {
if (state != path) {
return;
}
state = null;
}
}
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
ViewIntentFilePathNotifier.new,
);
@@ -1,23 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
abstract class ViewIntentHandler {
void init();
Future<void> onAppResumed();
Future<void> flushDeferredViewIntent();
Future<void> handle(ViewIntentPayload attachment);
}
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
if (Platform.isAndroid) {
return AndroidViewIntentHandler(ref);
}
return const StubViewIntentHandler();
});
@@ -1,103 +0,0 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:logging/logging.dart';
class AndroidViewIntentHandler implements ViewIntentHandler {
final Ref _ref;
final ViewIntentService _viewIntentService;
final ViewIntentAssetResolver _viewIntentAssetResolver;
final AppRouter _router;
static final Logger _logger = Logger('ViewIntentHandler');
AndroidViewIntentHandler(Ref ref)
: _ref = ref,
_viewIntentService = ref.read(viewIntentServiceProvider),
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
_router = ref.watch(appRouterProvider);
@override
void init() {
// Covers cold start from a view intent before the first lifecycle "resumed".
unawaited(onAppResumed());
}
@override
Future<void> onAppResumed() => _checkForViewIntent();
@override
Future<void> flushDeferredViewIntent() => _flushPending();
Future<void> _checkForViewIntent() async {
final attachment = await _viewIntentService.consumeViewIntent();
if (attachment != null) {
await handle(attachment);
return;
}
if (_ref.read(viewIntentPendingProvider) == null) {
await _viewIntentService.cleanupStaleTempFiles();
}
}
Future<void> _flushPending() async {
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
_logger.info('flushPending, pendingAttachment:$pendingAttachment');
if (pendingAttachment != null) {
await handle(pendingAttachment);
}
}
@override
Future<void> handle(ViewIntentPayload attachment) async {
_logger.info(
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
);
if (!_ref.read(authProvider).isAuthenticated) {
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
return;
}
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
await _openAssetViewer(
resolvedAsset.asset,
resolvedAsset.timelineService,
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
);
}
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
final notifier = _ref.read(assetViewerProvider.notifier);
notifier.reset();
if (asset.isVideo) {
notifier.setControls(false);
}
notifier.setAsset(asset);
if (viewIntentFilePath != null) {
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
} else {
_ref.read(viewIntentFilePathProvider.notifier).clear();
unawaited(_viewIntentService.cleanupManagedTempFile());
}
await _router.replaceAll([
const TabShellRoute(),
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
]);
}
}
@@ -1,18 +0,0 @@
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
class StubViewIntentHandler implements ViewIntentHandler {
const StubViewIntentHandler();
@override
void init() {}
@override
Future<void> onAppResumed() async {}
@override
Future<void> flushDeferredViewIntent() async {}
@override
Future<void> handle(ViewIntentPayload attachment) async {}
}
@@ -1,39 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
ViewIntentPendingNotifier.new,
);
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
static const _ttl = Duration(minutes: 10);
DateTime? _deferredAt;
@override
ViewIntentPayload? build() => null;
void defer(ViewIntentPayload attachment) {
_deferredAt = ref.read(viewIntentNowProvider)();
state = attachment;
}
ViewIntentPayload? takeIfFresh() {
final attachment = state;
final deferredAt = _deferredAt;
state = null;
_deferredAt = null;
if (attachment == null) {
return null;
}
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
return null;
}
return attachment;
}
}
@@ -151,7 +151,7 @@ class ForegroundUploadService {
List<File> files, { List<File> files, {
Completer<void>? cancelToken, Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId, String remoteAssetId)? onSuccess, void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError, void Function(String fileId, String errorMessage)? onError,
}) async { }) async {
if (files.isEmpty) { if (files.isEmpty) {
@@ -171,7 +171,7 @@ class ForegroundUploadService {
); );
if (result.isSuccess) { if (result.isSuccess) {
onSuccess?.call(fileId, result.remoteAssetId!); onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) { } else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!); onError?.call(fileId, result.errorMessage!);
} }
@@ -1,110 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
class ViewIntentService {
final ViewIntentHostApi _viewIntentHostApi;
final Future<Directory> Function() _temporaryDirectory;
String? _managedTempFilePath;
final Set<String> _activeUploadPaths = {};
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
Future<ViewIntentPayload?> consumeViewIntent() async {
try {
return await _viewIntentHostApi.consumeViewIntent();
} catch (_) {
// Ignore errors - view intent might not be present
return null;
}
}
Future<void> setManagedTempFilePath(String path) async {
final previous = _managedTempFilePath;
if (previous == path) {
return;
}
_managedTempFilePath = path;
if (previous != null) {
await cleanupTempFile(previous);
}
}
Future<void> cleanupManagedTempFile() async {
final path = _managedTempFilePath;
_managedTempFilePath = null;
if (path != null) {
await cleanupTempFile(path);
}
}
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
if (_managedTempFilePath == path) {
_managedTempFilePath = null;
}
await cleanupTempFile(path);
}
Future<void> cleanupTempFile(String path) async {
if (!_isManagedTempFile(path)) {
return;
}
if (_activeUploadPaths.contains(path)) {
return;
}
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
Future<void> cleanupStaleTempFiles() async {
try {
final tempDirectory = await _temporaryDirectory();
await for (final entity in tempDirectory.list()) {
if (entity is! File) {
continue;
}
final path = entity.path;
if (!_isManagedTempFile(path) ||
path == _managedTempFilePath ||
_activeUploadPaths.contains(path)) {
continue;
}
await entity.delete();
}
} catch (_) {
// Best-effort cleanup only.
}
}
void markUploadActive(String path) {
_activeUploadPaths.add(path);
}
Future<void> markUploadInactive(String path) async {
if (!_activeUploadPaths.remove(path)) {
return;
}
if (_managedTempFilePath != path) {
await cleanupTempFile(path);
}
}
bool _isManagedTempFile(String path) {
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
}
}
@@ -1,69 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class ViewIntentResolvedAsset {
final BaseAsset asset;
final TimelineService timelineService;
final String? viewIntentFilePath;
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
}
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
(ref) => ViewIntentAssetResolver(
localAssetRepository: ref.read(localAssetRepository),
timelineFactory: ref.read(timelineFactoryProvider),
),
);
class ViewIntentAssetResolver {
final DriftLocalAssetRepository _localAssetRepository;
final TimelineFactory _timelineFactory;
static final Logger _logger = Logger('ViewIntentAssetResolver');
const ViewIntentAssetResolver({
required DriftLocalAssetRepository localAssetRepository,
required TimelineFactory timelineFactory,
}) : _localAssetRepository = localAssetRepository,
_timelineFactory = timelineFactory;
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
final localAssetId = attachment.localAssetId;
final path = attachment.path;
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
if (localAssetId == null && path == null) {
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
}
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
final asset = localAsset ?? _toTransientAsset(attachment);
return ViewIntentResolvedAsset(
asset: asset,
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
viewIntentFilePath: localAsset == null ? path : null,
);
}
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
final now = DateTime.now();
return LocalAsset(
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
name: attachment.fileName,
type: attachment.isVideo ? AssetType.video : AssetType.image,
createdAt: now,
updatedAt: now,
isEdited: false,
playbackStyle: attachment.playbackStyle,
);
}
}
@@ -21,7 +21,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@@ -183,11 +182,9 @@ class LoginForm extends HookConsumerWidget {
Future<void> handleSyncFlow() async { Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
await backgroundManager.syncLocal(full: true); await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote(); await backgroundManager.syncRemote();
await viewIntentHandler.flushDeferredViewIntent();
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();
if (MetadataRepository.instance.appConfig.backup.syncAlbums) { if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
@@ -262,7 +259,7 @@ class LoginForm extends HookConsumerWidget {
} }
unawaited(handleSyncFlow()); unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect(); ref.read(websocketProvider.notifier).connect();
unawaited(context.router.replaceAll([const TabShellRoute()])); unawaited(context.replaceRoute(const TabShellRoute()));
return; return;
} }
} catch (error) { } catch (error) {
@@ -349,7 +346,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission(); await getManageMediaPermission();
} }
unawaited(handleSyncFlow()); unawaited(handleSyncFlow());
unawaited(context.router.replaceAll([const TabShellRoute()])); unawaited(context.replaceRoute(const TabShellRoute()));
return; return;
} }
} catch (error, stack) { } catch (error, stack) {
+1 -2
View File
@@ -29,8 +29,7 @@ run = [
"dart run pigeon --input pigeon/background_worker_lock_api.dart", "dart run pigeon --input pigeon/background_worker_lock_api.dart",
"dart run pigeon --input pigeon/connectivity_api.dart", "dart run pigeon --input pigeon/connectivity_api.dart",
"dart run pigeon --input pigeon/network_api.dart", "dart run pigeon --input pigeon/network_api.dart",
"dart run pigeon --input pigeon/view_intent_api.dart", "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
] ]
[tasks."codegen:translation"] [tasks."codegen:translation"]
+2 -1
View File
@@ -5,7 +5,8 @@ import 'package:pigeon/pigeon.dart';
dartOut: 'lib/platform/local_image_api.g.dart', dartOut: 'lib/platform/local_image_api.g.dart',
swiftOut: 'ios/Runner/Images/LocalImages.g.swift', swiftOut: 'ios/Runner/Images/LocalImages.g.swift',
swiftOptions: SwiftOptions(includeErrorClass: false), swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', kotlinOut:
'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'),
dartOptions: DartOptions(), dartOptions: DartOptions(),
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
-24
View File
@@ -1,24 +0,0 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/view_intent_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
class ViewIntentPayload {
final String? path;
final String mimeType;
final String? localAssetId;
const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
}
@HostApi()
abstract class ViewIntentHostApi {
@async
ViewIntentPayload? consumeViewIntent();
}
@@ -1,261 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/models/auth/auth_state.model.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
class MockAppRouter extends Mock implements AppRouter {}
class MockAuthService extends Mock implements AuthService {}
class MockApiService extends Mock implements ApiService {}
class MockUserService extends Mock implements UserService {}
class MockSecureStorageService extends Mock implements SecureStorageService {}
class MockWidgetService extends Mock implements WidgetService {}
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
class FakeTimelineService extends Fake implements TimelineService {}
class TestViewIntentService extends ViewIntentService {
ViewIntentPayload? consumedAttachment;
int cleanupStaleTempFilesCalls = 0;
int cleanupManagedTempFileCalls = 0;
final List<String> managedTempPaths = [];
TestViewIntentService() : super(MockViewIntentHostApi());
@override
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
@override
Future<void> cleanupStaleTempFiles() async {
cleanupStaleTempFilesCalls++;
}
@override
Future<void> cleanupManagedTempFile() async {
cleanupManagedTempFileCalls++;
}
@override
Future<void> setManagedTempFilePath(String path) async {
managedTempPaths.add(path);
}
}
class TestAuthNotifier extends AuthNotifier {
TestAuthNotifier(Ref ref, AuthState initial)
: super(
MockAuthService(),
MockApiService(),
MockUserService(),
MockSecureStorageService(),
MockWidgetService(),
ref,
) {
state = initial;
}
void setAuthenticated(bool isAuthenticated) {
state = state.copyWith(isAuthenticated: isAuthenticated);
}
}
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late TestViewIntentService viewIntentService;
late MockViewIntentAssetResolver resolver;
late MockAppRouter router;
late TestAuthNotifier authNotifier;
late ProviderContainer container;
late AndroidViewIntentHandler handler;
late ViewIntentPayload payload;
late LocalAsset deepLinkAsset;
late TimelineService deepLinkTimelineService;
setUpAll(() {
registerFallbackValue(FakePageRouteInfo());
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
registerFallbackValue(FakeTimelineService());
registerFallbackValue(
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
);
});
setUp(() async {
viewIntentService = TestViewIntentService();
resolver = MockViewIntentAssetResolver();
router = MockAppRouter();
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
deepLinkAsset = _localAsset(id: 'local-1');
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
when(() => router.replaceAll(any())).thenAnswer((_) async {});
container = ProviderContainer(
overrides: [
viewIntentServiceProvider.overrideWithValue(viewIntentService),
viewIntentAssetResolverProvider.overrideWithValue(resolver),
appRouterProvider.overrideWithValue(router),
authProvider.overrideWith((ref) {
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
return authNotifier;
}),
],
);
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
handler = container.read(_handlerProvider);
addTearDown(() async {
await deepLinkTimelineService.dispose();
container.dispose();
});
});
test('handle defers unauthenticated attachment', () async {
authNotifier.setAuthenticated(false);
await handler.handle(payload);
expect(container.read(viewIntentPendingProvider), payload);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('flushDeferredViewIntent consumes the pending attachment and routes the viewer', (tester) async {
authNotifier.setAuthenticated(false);
container.read(viewIntentPendingProvider.notifier).defer(payload);
authNotifier.setAuthenticated(true);
when(() => resolver.resolve(payload)).thenAnswer((_) async {
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
});
unawaited(handler.flushDeferredViewIntent());
await tester.pump();
await tester.pump();
await tester.idle();
expect(container.read(viewIntentPendingProvider), isNull);
verify(() => resolver.resolve(payload)).called(1);
});
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
await handler.flushDeferredViewIntent();
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed cleans stale temp files when no attachment is present', () async {
viewIntentService.consumedAttachment = null;
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
verifyNever(() => resolver.resolve(any()));
});
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
viewIntentService.consumedAttachment = null;
container.read(viewIntentPendingProvider.notifier).defer(payload);
await handler.onAppResumed();
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
verifyNever(() => resolver.resolve(any()));
});
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
viewIntentService.consumedAttachment = payload;
when(() => resolver.resolve(payload)).thenAnswer(
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
);
unawaited(handler.onAppResumed());
await tester.pump();
await tester.pump();
await tester.pump();
await tester.idle();
verify(() => resolver.resolve(payload)).called(1);
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
// main timeline — mirrors the home-screen widget navigation pattern.
final captured = verify(() => router.replaceAll(captureAny())).captured;
expect(captured, hasLength(1));
final routes = captured.single as List<PageRouteInfo<dynamic>>;
expect(routes, hasLength(2));
expect(routes[0].routeName, TabShellRoute.name);
expect(routes[1].routeName, AssetViewerRoute.name);
});
}
AuthState _authState({required bool isAuthenticated}) {
return AuthState(
deviceId: 'device-1',
userId: 'user-1',
userEmail: 'user@example.com',
isAuthenticated: isAuthenticated,
name: 'User',
isAdmin: false,
profileImagePath: '',
);
}
LocalAsset _localAsset({required String id}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
final timelineService = _timelineServiceFromAssets(assets, origin);
// Spin a few async ticks so the internal bucket subscription has populated
// the buffer before tests start asserting against totalAssets.
for (var i = 0; i < 20 && timelineService.totalAssets != assets.length; i++) {
await Future<void>.delayed(Duration.zero);
}
return timelineService;
}
@@ -1,64 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
void main() {
late DateTime now;
late ProviderContainer container;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
now = DateTime(2026, 4, 17, 12);
container = ProviderContainer(
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
);
addTearDown(container.dispose);
});
test('defer stores pending attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
expect(container.read(viewIntentPendingProvider), attachment);
});
test('takeIfFresh returns pending attachment once', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(first, attachment);
expect(second, isNull);
});
test('takeIfFresh drops expired attachment', () {
container.read(viewIntentPendingProvider.notifier).defer(attachment);
now = now.add(const Duration(minutes: 11));
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, isNull);
expect(container.read(viewIntentPendingProvider), isNull);
});
test('newer deferred attachment replaces older one', () {
final newerAttachment = ViewIntentPayload(
path: '/tmp/file-2.jpg',
mimeType: 'image/jpeg',
localAssetId: '43',
);
container.read(viewIntentPendingProvider.notifier).defer(attachment);
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
expect(result, newerAttachment);
});
}
@@ -1,123 +0,0 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
import 'package:mocktail/mocktail.dart';
import '../infrastructure/repository.mock.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockTimelineFactory timelineFactory;
late List<TimelineService> createdTimelineServices;
late ProviderContainer container;
setUp(() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
timelineFactory = MockTimelineFactory();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
createdTimelineServices.add(timelineService);
return timelineService;
});
container = ProviderContainer(
overrides: [
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
],
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
container.dispose();
});
});
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
expect(result.asset, equals(localAsset));
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
});
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
});
test('returns transient asset for path-only attachment', () async {
final result = await _resolve(
container,
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
);
expect(result.asset, isA<LocalAsset>());
expect(result.timelineService.origin, TimelineOrigin.deepLink);
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
final asset = result.asset as LocalAsset;
expect(asset.localId, startsWith('-'));
expect(asset.name, 'incoming.webp');
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
});
test('throws when neither localAssetId nor path is provided', () async {
await expectLater(
_resolve(container, _payload(localAssetId: null, path: null)),
throwsA(isA<StateError>()),
);
});
}
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
return container.read(viewIntentAssetResolverProvider).resolve(payload);
}
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
}
LocalAsset _localAsset({required String id, String? checksum}) {
return LocalAsset(
id: id,
name: '$id.jpg',
checksum: checksum,
type: AssetType.image,
createdAt: DateTime(2026, 4, 20),
updatedAt: DateTime(2026, 4, 20),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
);
}
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
return TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: origin,
));
}
@@ -1,119 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/platform/view_intent_api.g.dart';
import 'package:immich_mobile/services/view_intent.service.dart';
import 'package:mocktail/mocktail.dart';
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
void main() {
late MockViewIntentHostApi hostApi;
late ViewIntentService service;
late Directory tempRoot;
late Directory cacheDir;
final attachment = ViewIntentPayload(
path: '/tmp/file.jpg',
mimeType: 'image/jpeg',
localAssetId: '42',
);
setUp(() {
hostApi = MockViewIntentHostApi();
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
});
tearDown(() async {
clearInteractions(hostApi);
if (await tempRoot.exists()) {
await tempRoot.delete(recursive: true);
}
});
test('consumeViewIntent returns null when no attachment', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent returns attachment when present', () async {
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
final result = await service.consumeViewIntent();
expect(result, attachment);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('consumeViewIntent swallows host api errors', () async {
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
final result = await service.consumeViewIntent();
expect(result, isNull);
verify(() => hostApi.consumeViewIntent()).called(1);
});
test('setManagedTempFilePath cleans previous managed temp file', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
await service.setManagedTempFilePath(firstFile.path);
await service.setManagedTempFilePath(secondFile.path);
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isTrue);
await service.cleanupManagedTempFile();
expect(await secondFile.exists(), isFalse);
});
test('cleanupTempFile defers deletion while an upload is active', () async {
final tempFile = File('${cacheDir.path}/view_intent_in_flight.jpg')..writeAsStringSync('bytes');
service.markUploadActive(tempFile.path);
await service.cleanupTempFile(tempFile.path);
expect(await tempFile.exists(), isTrue, reason: 'active uploads block cleanup');
await service.markUploadInactive(tempFile.path);
expect(await tempFile.exists(), isFalse);
});
test('cleanupTempFile ignores non-managed paths', () async {
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
await service.cleanupTempFile(nonManagedFile.path);
expect(await nonManagedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
await service.cleanupStaleTempFiles();
expect(await firstFile.exists(), isFalse);
expect(await secondFile.exists(), isFalse);
expect(await unrelatedFile.exists(), isTrue);
});
test('cleanupStaleTempFiles skips paths with active uploads', () async {
final stale = File('${cacheDir.path}/view_intent_stale.jpg')..writeAsStringSync('stale');
final active = File('${cacheDir.path}/view_intent_active.jpg')..writeAsStringSync('active');
service.markUploadActive(active.path);
await service.cleanupStaleTempFiles();
expect(await stale.exists(), isFalse);
expect(await active.exists(), isTrue);
});
}
+5 -5
View File
@@ -758,8 +758,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../packages/sdk version: link:../packages/sdk
'@immich/ui': '@immich/ui':
specifier: ^0.77.0 specifier: ^0.79.0
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4)) version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0 specifier: 0.4.0
version: 0.4.0 version: 0.4.0
@@ -3204,8 +3204,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true hasBin: true
'@immich/ui@0.77.3': '@immich/ui@0.79.0':
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==} resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
peerDependencies: peerDependencies:
'@sveltejs/kit': ^2.13.0 '@sveltejs/kit': ^2.13.0
svelte: ^5.0.0 svelte: ^5.0.0
@@ -15879,7 +15879,7 @@ snapshots:
pg-connection-string: 2.13.0 pg-connection-string: 2.13.0
postgres: 3.4.9 postgres: 3.4.9
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))': '@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies: dependencies:
'@internationalized/date': 3.12.1 '@internationalized/date': 3.12.1
'@mdi/js': 7.4.47 '@mdi/js': 7.4.47
+2 -2
View File
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.77.0", "@immich/ui": "^0.79.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
@@ -103,7 +103,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -91,7 +91,7 @@
</div> </div>
</main> </main>
<header> <header>
<ControlAppBar showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant="inline" /> <Logo variant="inline" />
@@ -18,7 +18,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton, Logo, toastManager } from '@immich/ui'; import { IconButton, Logo, toastManager } from '@immich/ui';
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte'; import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
@@ -97,7 +97,7 @@
{/if} {/if}
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}> <ControlAppBar>
{#snippet leading()} {#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/"> <a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" /> <Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
@@ -1,97 +1,49 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
import { IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { onDestroy, onMount, type Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import type { ClassValue } from 'svelte/elements';
import { fly } from 'svelte/transition';
interface Props { interface Props {
showBackButton?: boolean;
backIcon?: string; backIcon?: string;
tailwindClasses?: string; class?: ClassValue;
forceDark?: boolean;
multiRow?: boolean;
onClose?: () => void; onClose?: () => void;
title?: Snippet | string;
leading?: Snippet; leading?: Snippet;
children?: Snippet; children?: Snippet;
trailing?: Snippet; trailing?: Snippet;
} }
let { let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
showBackButton = true,
backIcon = mdiClose,
tailwindClasses = '',
forceDark = false,
multiRow = false,
onClose = () => {},
leading,
children,
trailing,
}: Props = $props();
let appBarBorder = $state('border border-subtle');
const onScroll = () => {
if (window.scrollY > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
if (forceDark) {
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'border border-subtle';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', onScroll, { passive: true });
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', onScroll);
}
});
</script> </script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent"> <div class={['absolute top-0 w-full bg-transparent p-2']}>
<nav <ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
id="asset-selection-app-bar" {#if title || leading}
class={[ <ControlBarHeader>
'grid', {#if title}
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]', <ControlBarTitle>
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]', {#if typeof title === 'string'}
'justify-between lg:grid-cols-[25%_50%_25%]', {title}
appBarBorder, {:else}
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0', {@render title()}
tailwindClasses, {/if}
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray', </ControlBarTitle>
]} {/if}
> {@render leading?.()}
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}"> </ControlBarHeader>
{#if showBackButton} {/if}
<IconButton
aria-label={$t('close')}
onclick={onClose}
color="secondary"
shape="round"
variant="ghost"
icon={backIcon}
size="large"
/>
{/if}
{@render leading?.()}
</div>
<div class="w-full"> {#if children}
{@render children?.()} <ControlBarContent>
</div> {@render children()}
</ControlBarContent>
{/if}
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0"> {#if trailing}
{@render trailing?.()} <ControlBarOverflow>
</div> {@render trailing()}
</nav> </ControlBarOverflow>
{/if}
</ControlBar>
</div> </div>
@@ -7,19 +7,18 @@
type Props = { type Props = {
children?: Snippet; children?: Snippet;
forceDark?: boolean;
}; };
let { children, forceDark }: Props = $props(); let { children }: Props = $props();
const onClose = () => assetMultiSelectManager.clear(); const onClose = () => assetMultiSelectManager.clear();
const assets = $derived(assetMultiSelectManager.assets); const assets = $derived(assetMultiSelectManager.assets);
</script> </script>
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md"> <ControlAppBar {onClose} backIcon={mdiClose}>
{#snippet leading()} {#snippet leading()}
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}"> <div class="font-medium text-primary">
<p class="block sm:hidden">{assets.length}</p> <p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p> <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div> </div>
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidate, onNavigate } from '$app/navigation'; import { goto, invalidate, onNavigate } from '$app/navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte'; import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte'; import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
import AlbumTitle from './AlbumTitle.svelte';
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte'; import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte'; import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
@@ -78,6 +76,8 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import AlbumDescription from './AlbumDescription.svelte';
import AlbumTitle from './AlbumTitle.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -499,7 +499,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === AlbumPageViewMode.VIEW} {#if viewMode === AlbumPageViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
{#snippet trailing()} {#snippet trailing()}
<ActionButton action={Cast} /> <ActionButton action={Cast} />
@@ -2,11 +2,8 @@
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
@@ -37,6 +34,7 @@
mdiChevronLeft, mdiChevronLeft,
mdiChevronRight, mdiChevronRight,
mdiChevronUp, mdiChevronUp,
mdiClose,
mdiDotsVertical, mdiDotsVertical,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
@@ -54,6 +52,8 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
let memoryGallery: HTMLElement | undefined = $state(); let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state();
@@ -327,8 +327,8 @@
/> />
{#if assetMultiSelectManager.selectionActive} {#if assetMultiSelectManager.selectionActive}
<div class="dark sticky top-0 z-1"> <div class="sticky top-0 z-1 dark">
<AssetSelectControlBar forceDark> <AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)} {@const Actions = getAssetBulkActions($t)}
<CreateSharedLink /> <CreateSharedLink />
<IconButton <IconButton
@@ -365,22 +365,33 @@
<section <section
id="memory-viewer" id="memory-viewer"
class="w-full bg-immich-dark-gray" class="dark w-full text-white bg-immich-dark-gray"
bind:this={memoryWrapper} bind:this={memoryWrapper}
bind:clientHeight={viewport.height} bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
{#if current} {#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow> <div
{#snippet leading()} class="max-md:h-auto max-md:flex-col dark grid grid-cols-[100%] md:grid-cols-[25%_50%_25%] px-2 py-2 md:px-4 md:py-4"
{#if current} >
{#if current}
<div class="flex gap-2 md:gap-6 items-center">
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
/>
<p class="text-lg"> <p class="text-lg">
{$memoryLaneTitle(current.memory)} {$memoryLaneTitle(current.memory)}
</p> </p>
{/if} </div>
{/snippet} {/if}
<div class="dark flex place-content-center place-items-center gap-2"> <div class="dark flex w-full place-content-center place-items-center gap-2">
<IconButton <IconButton
shape="round" shape="round"
variant="ghost" variant="ghost"
@@ -438,7 +449,7 @@
</media-mute-button> </media-mute-button>
{/if} {/if}
</div> </div>
</ControlAppBar> </div>
{#if galleryInView} {#if galleryInView}
<div <div
@@ -462,7 +473,7 @@
</div> </div>
{/if} {/if}
<!-- Viewer --> <!-- Viewer -->
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}> <section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
<div <div
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]" class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
> >
@@ -47,7 +47,7 @@
<DownloadAction /> <DownloadAction />
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
{#snippet leading()} {#snippet leading()}
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg"> <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
{$t('partner_list_user_photos', { values: { user: data.partner.name } })} {$t('partner_list_user_photos', { values: { user: data.partner.name } })}
@@ -5,9 +5,6 @@
import { listNavigation } from '$lib/actions/list-navigation'; import { listNavigation } from '$lib/actions/list-navigation';
import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
@@ -54,6 +51,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import EditNameInput from './EditNameInput.svelte';
import MergeFaceSelector from './MergeFaceSelector.svelte';
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -493,7 +493,7 @@
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
{#if viewMode === PersonPageViewMode.VIEW_ASSETS} {#if viewMode === PersonPageViewMode.VIEW_ASSETS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}> <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
{#snippet trailing()} {#snippet trailing()}
<ContextMenuButton <ContextMenuButton
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]} items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
@@ -387,8 +387,7 @@
{:else} {:else}
<div class="fixed inset-s-0 top-0 z-2 w-full"> <div class="fixed inset-s-0 top-0 z-2 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div> <div class="mx-auto w-full max-w-2xl pe-2">
<div class="w-full flex-1 ps-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} /> <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div> </div>
</ControlAppBar> </ControlAppBar>