Merge branch 'main' into fix/save-album-sort

This commit is contained in:
Yaros 2025-09-17 18:24:31 +02:00 committed by GitHub
commit 28a8a8c89c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 1066 additions and 573 deletions

View File

@ -533,6 +533,7 @@
"background_backup_running_error": "Background backup is currently running, cannot start manual backup",
"background_location_permission": "Background location permission",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name",
"background_options": "Background Options",
"backup": "Backup",
"backup_album_selection_page_albums_device": "Albums on device ({count})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
@ -540,6 +541,7 @@
"backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets",
"backup_albums_sync": "Backup albums synchronization",
"backup_all": "All",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
@ -656,6 +658,8 @@
"change_pin_code": "Change PIN code",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging",
"charging_requirement_mobile_backup": "Background backup requires the device to be charging",
"check_corrupt_asset_backup": "Check for corrupt asset backups",
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
@ -1351,6 +1355,7 @@
"name_or_nickname": "Name or nickname",
"network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements": "Network Requirements",
"network_requirements_updated": "Network requirements changed, resetting backup queue",
"networking_settings": "Networking",
"networking_subtitle": "Manage the server endpoint settings",

View File

@ -1,6 +1,6 @@
[tools]
node = "22.19.0"
flutter = "3.35.3"
flutter = "3.35.4"
pnpm = "10.14.0"
dart = "3.8.2"

View File

@ -1,3 +1,3 @@
{
"flutter": "3.35.3"
"flutter": "3.35.4"
}

View File

@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.35.3",
"dart.flutterSdkPath": ".fvm/versions/3.35.4",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [120]

View File

@ -3,6 +3,7 @@ package app.alextran.immich
import android.app.Application
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
@ -14,6 +15,8 @@ class ImmichApp : Application() {
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.
// As a workaround, we also run a backup check when initializing the application
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}
}

View File

@ -3,6 +3,7 @@ package app.alextran.immich
import android.content.Context
import android.os.Build
import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.connectivity.ConnectivityApi
@ -25,6 +26,7 @@ class MainActivity : FlutterFragmentActivity() {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
flutterEngine.plugins.add(BackgroundEngineLock())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val nativeSyncApiImpl =

View File

@ -0,0 +1,33 @@
package app.alextran.immich.background
import android.util.Log
import androidx.work.WorkManager
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.plugins.FlutterPlugin
import java.util.concurrent.atomic.AtomicInteger
private const val TAG = "BackgroundEngineLock"
class BackgroundEngineLock : FlutterPlugin {
companion object {
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
var engineCount = AtomicInteger(0)
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// work manager task is running while the main app is opened, cancel the worker
if (engineCount.incrementAndGet() > 1 && FlutterEngineCache.getInstance()
.get(ENGINE_CACHE_KEY) != null
) {
WorkManager.getInstance(binding.applicationContext)
.cancelUniqueWork(BackgroundWorkerApiImpl.BACKGROUND_WORKER_NAME)
FlutterEngineCache.getInstance().remove(ENGINE_CACHE_KEY)
}
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
engineCount.decrementAndGet()
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
}
}

View File

@ -37,6 +37,36 @@ private object BackgroundWorkerPigeonUtils {
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
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) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
@ -50,18 +80,63 @@ class FlutterError (
override val message: String? = null,
val details: Any? = null
) : Throwable()
/** Generated class from Pigeon that represents data sent in messages. */
data class BackgroundWorkerSettings (
val requiresCharging: Boolean,
val minimumDelaySeconds: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BackgroundWorkerSettings {
val requiresCharging = pigeonVar_list[0] as Boolean
val minimumDelaySeconds = pigeonVar_list[1] as Long
return BackgroundWorkerSettings(requiresCharging, minimumDelaySeconds)
}
}
fun toList(): List<Any?> {
return listOf(
requiresCharging,
minimumDelaySeconds,
)
}
override fun equals(other: Any?): Boolean {
if (other !is BackgroundWorkerSettings) {
return false
}
if (this === other) {
return true
}
return BackgroundWorkerPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
return when (type) {
129.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BackgroundWorkerSettings.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
when (value) {
is BackgroundWorkerSettings -> {
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 BackgroundWorkerFgHostApi {
fun enable()
fun configure(settings: BackgroundWorkerSettings)
fun disable()
companion object {
@ -89,6 +164,24 @@ interface BackgroundWorkerFgHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val settingsArg = args[0] as BackgroundWorkerSettings
val wrapped: List<Any?> = try {
api.configure(settingsArg)
listOf(null)
} catch (exception: Throwable) {
BackgroundWorkerPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
if (api != null) {

View File

@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import java.util.concurrent.TimeUnit
@ -75,6 +76,9 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
engine = FlutterEngine(ctx)
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
FlutterEngineCache.getInstance()
.put(BackgroundEngineLock.ENGINE_CACHE_KEY, engine!!)
// Register custom plugins
MainActivity.registerPlugins(ctx, engine!!)
@ -188,6 +192,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
isComplete = true
engine?.destroy()
engine = null
FlutterEngineCache.getInstance().remove(BackgroundEngineLock.ENGINE_CACHE_KEY);
flutterApi = null
notificationManager.cancel(NOTIFICATION_ID)
waitForForegroundPromotion()

View File

@ -1,6 +1,7 @@
package app.alextran.immich.background
import android.content.Context
import android.content.SharedPreferences
import android.provider.MediaStore
import android.util.Log
import androidx.work.BackoffPolicy
@ -10,7 +11,7 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
private const val TAG = "BackgroundUploadImpl"
private const val TAG = "BackgroundWorkerApiImpl"
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
private val ctx: Context = context.applicationContext
@ -19,25 +20,34 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
enqueueMediaObserver(ctx)
}
override fun configure(settings: BackgroundWorkerSettings) {
BackgroundWorkerPreferences(ctx).updateSettings(settings)
enqueueMediaObserver(ctx)
}
override fun disable() {
WorkManager.getInstance(ctx).cancelUniqueWork(OBSERVER_WORKER_NAME)
WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME)
WorkManager.getInstance(ctx).apply {
cancelUniqueWork(OBSERVER_WORKER_NAME)
cancelUniqueWork(BACKGROUND_WORKER_NAME)
}
Log.i(TAG, "Cancelled background upload tasks")
}
companion object {
private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1"
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
fun enqueueMediaObserver(ctx: Context) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(30, TimeUnit.SECONDS)
.setTriggerContentMaxDelay(3, TimeUnit.MINUTES)
.build()
val settings = BackgroundWorkerPreferences(ctx).getSettings()
val constraints = Constraints.Builder().apply {
addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
setTriggerContentUpdateDelay(settings.minimumDelaySeconds, TimeUnit.SECONDS)
setTriggerContentMaxDelay(settings.minimumDelaySeconds * 10, TimeUnit.MINUTES)
setRequiresCharging(settings.requiresCharging)
}.build()
val work = OneTimeWorkRequest.Builder(MediaObserver::class.java)
.setConstraints(constraints)
@ -45,7 +55,10 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
WorkManager.getInstance(ctx)
.enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME")
Log.i(
TAG,
"Enqueued media observer worker with name: $OBSERVER_WORKER_NAME and settings: $settings"
)
}
fun enqueueBackgroundWorker(ctx: Context) {
@ -56,9 +69,39 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work)
.enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.KEEP, work)
Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME")
}
}
}
private class BackgroundWorkerPreferences(private val ctx: Context) {
companion object {
private const val SHARED_PREF_NAME = "Immich::BackgroundWorker"
private const val SHARED_PREF_MIN_DELAY_KEY = "BackgroundWorker::minDelaySeconds"
private const val SHARED_PREF_REQUIRE_CHARGING_KEY = "BackgroundWorker::requireCharging"
private const val DEFAULT_MIN_DELAY_SECONDS = 30L
private const val DEFAULT_REQUIRE_CHARGING = false
}
private val sp: SharedPreferences by lazy {
ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
}
fun updateSettings(settings: BackgroundWorkerSettings) {
sp.edit().apply {
putLong(SHARED_PREF_MIN_DELAY_KEY, settings.minimumDelaySeconds)
putBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, settings.requiresCharging)
apply()
}
}
fun getSettings(): BackgroundWorkerSettings {
return BackgroundWorkerSettings(
minimumDelaySeconds = sp.getLong(SHARED_PREF_MIN_DELAY_KEY, DEFAULT_MIN_DELAY_SECONDS),
requiresCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING_KEY, DEFAULT_REQUIRE_CHARGING),
)
}
}

View File

@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
@ -19,7 +18,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import java.util.Base64
import java.util.HashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future
@ -202,8 +200,10 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val source = ImageDecoder.createSource(resolver, uri)
signal.throwIfCanceled()
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val sampleSize = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
decoder.setTargetSampleSize(sampleSize)
if (targetWidth > 0 && targetHeight > 0) {
val sample = max(1, min(info.size.width / targetWidth, info.size.height / targetHeight))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}

View File

@ -50,11 +50,119 @@ private func nilOrValue<T>(_ value: Any?) -> T? {
return value as! T?
}
func deepEqualsBackgroundWorker(_ lhs: Any?, _ rhs: Any?) -> Bool {
let cleanLhs = nilOrValue(lhs) as Any?
let cleanRhs = nilOrValue(rhs) as Any?
switch (cleanLhs, cleanRhs) {
case (nil, nil):
return true
case (nil, _), (_, nil):
return false
case is (Void, Void):
return true
case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable):
return cleanLhsHashable == cleanRhsHashable
case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]):
guard cleanLhsArray.count == cleanRhsArray.count else { return false }
for (index, element) in cleanLhsArray.enumerated() {
if !deepEqualsBackgroundWorker(element, cleanRhsArray[index]) {
return false
}
}
return true
case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]):
guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false }
for (key, cleanLhsValue) in cleanLhsDictionary {
guard cleanRhsDictionary.index(forKey: key) != nil else { return false }
if !deepEqualsBackgroundWorker(cleanLhsValue, cleanRhsDictionary[key]!) {
return false
}
}
return true
default:
// Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue.
return false
}
}
func deepHashBackgroundWorker(value: Any?, hasher: inout Hasher) {
if let valueList = value as? [AnyHashable] {
for item in valueList { deepHashBackgroundWorker(value: item, hasher: &hasher) }
return
}
if let valueDict = value as? [AnyHashable: AnyHashable] {
for key in valueDict.keys {
hasher.combine(key)
deepHashBackgroundWorker(value: valueDict[key]!, hasher: &hasher)
}
return
}
if let hashableValue = value as? AnyHashable {
hasher.combine(hashableValue.hashValue)
}
return hasher.combine(String(describing: value))
}
/// Generated class from Pigeon that represents data sent in messages.
struct BackgroundWorkerSettings: Hashable {
var requiresCharging: Bool
var minimumDelaySeconds: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BackgroundWorkerSettings? {
let requiresCharging = pigeonVar_list[0] as! Bool
let minimumDelaySeconds = pigeonVar_list[1] as! Int64
return BackgroundWorkerSettings(
requiresCharging: requiresCharging,
minimumDelaySeconds: minimumDelaySeconds
)
}
func toList() -> [Any?] {
return [
requiresCharging,
minimumDelaySeconds,
]
}
static func == (lhs: BackgroundWorkerSettings, rhs: BackgroundWorkerSettings) -> Bool {
return deepEqualsBackgroundWorker(lhs.toList(), rhs.toList()) }
func hash(into hasher: inout Hasher) {
deepHashBackgroundWorker(value: toList(), hasher: &hasher)
}
}
private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
case 129:
return BackgroundWorkerSettings.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
}
}
private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? BackgroundWorkerSettings {
super.writeByte(129)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
}
}
private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter {
@ -74,6 +182,7 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol BackgroundWorkerFgHostApi {
func enable() throws
func configure(settings: BackgroundWorkerSettings) throws
func disable() throws
}
@ -96,6 +205,21 @@ class BackgroundWorkerFgHostApiSetup {
} else {
enableChannel.setMessageHandler(nil)
}
let configureChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
configureChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let settingsArg = args[0] as! BackgroundWorkerSettings
do {
try api.configure(settings: settingsArg)
reply(wrapResult(nil))
} catch {
reply(wrapError(error))
}
}
} else {
configureChannel.setMessageHandler(nil)
}
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
disableChannel.setMessageHandler { _, reply in

View File

@ -8,6 +8,10 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
print("BackgroundUploadImpl:enbale Background worker scheduled")
}
func configure(settings: BackgroundWorkerSettings) throws {
// Android only
}
func disable() throws {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);

View File

@ -105,7 +105,7 @@ class ThumbnailApiImpl: ThumbnailApi {
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: CGSize(width: Double(width), height: Double(height)),
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in

View File

@ -43,6 +43,17 @@ class BackgroundWorkerFgService {
// TODO: Move this call to native side once old timeline is removed
Future<void> enable() => _foregroundHostApi.enable();
Future<void> configure({int? minimumDelaySeconds, bool? requireCharging}) => _foregroundHostApi.configure(
BackgroundWorkerSettings(
minimumDelaySeconds:
minimumDelaySeconds ??
Store.get(AppSettingsEnum.backupTriggerDelay.storeKey, AppSettingsEnum.backupTriggerDelay.defaultValue),
requiresCharging:
requireCharging ??
Store.get(AppSettingsEnum.backupRequireCharging.storeKey, AppSettingsEnum.backupRequireCharging.defaultValue),
),
);
Future<void> disable() => _foregroundHostApi.disable();
}

View File

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -11,13 +9,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@RoutePage()
@ -29,8 +25,6 @@ class DriftBackupPage extends ConsumerStatefulWidget {
}
class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
Timer? _countPoller;
@override
void initState() {
super.initState();
@ -39,42 +33,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return;
}
if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) {
_startCountPolling();
}
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
}
void _startCountPolling() {
_countPoller?.cancel();
_countPoller = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (!mounted) {
timer.cancel();
return;
}
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
timer.cancel();
return;
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
});
}
void _stopCountPolling() {
_countPoller?.cancel();
_countPoller = null;
}
@override
void dispose() {
_stopCountPolling();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selectedAlbum = ref
@ -94,12 +55,10 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id);
_startCountPolling();
}
Future<void> stopBackup() async {
await backupNotifier.cancel();
_stopCountPolling();
}
return Scaffold(

View File

@ -25,6 +25,57 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? 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) {
return a.length == b.length &&
a.entries.every(
(MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]),
);
}
return a == b;
}
class BackgroundWorkerSettings {
BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
bool requiresCharging;
int minimumDelaySeconds;
List<Object?> _toList() {
return <Object?>[requiresCharging, minimumDelaySeconds];
}
Object encode() {
return _toList();
}
static BackgroundWorkerSettings decode(Object result) {
result as List<Object?>;
return BackgroundWorkerSettings(requiresCharging: result[0]! as bool, minimumDelaySeconds: result[1]! as int);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BackgroundWorkerSettings || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(encode(), other.encode());
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@ -32,6 +83,9 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is BackgroundWorkerSettings) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@ -40,6 +94,8 @@ class _PigeonCodec extends StandardMessageCodec {
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
return BackgroundWorkerSettings.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@ -82,6 +138,29 @@ class BackgroundWorkerFgHostApi {
}
}
Future<void> configure(BackgroundWorkerSettings settings) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.configure$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[settings]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
Future<void> disable() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$pigeonVar_messageChannelSuffix';

View File

@ -87,7 +87,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.45,
initialChildSize: widget.minChildSize ?? 0.15,
minChildSize: widget.minChildSize,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,

View File

@ -84,7 +84,8 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.45,
initialChildSize: 0.22,
minChildSize: 0.22,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [

View File

@ -4,6 +4,8 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@ -88,13 +90,26 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
}
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final request = this.request = LocalImageRequest(
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(request, decode);
if (!Store.get(StoreKey.loadOriginal, false)) {
return;
}
if (isCancelled) {
evict();
return;
}
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
yield* loadRequest(request, decode);
}
@override

View File

@ -356,7 +356,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
children: [
timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[
const Positioned(top: 60, left: 25, child: _MultiSelectStatusButton()),
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(
height: kToolbarHeight,
child: Center(child: _MultiSelectStatusButton()),
),
),
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],

View File

@ -12,8 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class EnqueueStatus {
final int enqueueCount;
@ -234,6 +234,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
switch (update.status) {
case TaskStatus.complete:
if (update.task.group == kBackupGroup) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
}
// Remove the completed task from the upload items
if (state.uploadItems.containsKey(taskId)) {
Future.delayed(const Duration(milliseconds: 1000), () {

View File

@ -52,6 +52,9 @@ enum AppSettingsEnum<T> {
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false);
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@ -102,7 +102,7 @@ enum ActionButtonType {
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
context.asset.hasLocal,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,

View File

@ -65,7 +65,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
// Handle migration only for this version
// TODO: remove when old timeline is removed
final needBetaMigration = Store.tryGet(StoreKey.needBetaMigration);
if (version == 15 && needBetaMigration == null) {
if (version >= 15 && needBetaMigration == null) {
// Check both databases directly instead of relying on cache
final isBeta = Store.tryGet(StoreKey.betaTimeline);
@ -73,7 +73,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
// For new installations, no migration needed
// For existing installations, only migrate if beta timeline is not enabled (null or false)
if (isNewInstallation || isBeta == true) {
if (isNewInstallation || isBeta == true || (version > 15 && isBeta == null)) {
await Store.put(StoreKey.needBetaMigration, false);
await Store.put(StoreKey.betaTimeline, true);
} else {

View File

@ -75,86 +75,79 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
if (isMultiSelectEnabled) {
return SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 452),
},
);
} else {
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: actionIconColor,
shadows: actionIconShadows,
),
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
title: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
);
},
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: isMultiSelectEnabled
? const SizedBox.shrink()
: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: actionIconColor,
shadows: actionIconShadows,
),
);
},
),
);
}
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
title: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
);
},
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
),
);
},
),
);
}
}

View File

@ -1,14 +1,19 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
@ -18,12 +23,40 @@ class DriftBackupSettings extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsSubPageScaffold(
return SettingsSubPageScaffold(
settings: [
_UseWifiForUploadVideosButton(),
_UseWifiForUploadPhotosButton(),
Divider(indent: 16, endIndent: 16),
_AlbumSyncActionButton(),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"network_requirements".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
),
const _UseWifiForUploadVideosButton(),
const _UseWifiForUploadPhotosButton(),
if (CurrentPlatform.isAndroid) ...[
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"background_options".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
const _BackupOnlyWhenChargingButton(),
const _BackupDelaySlider(),
],
const Divider(),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
"backup_albums_sync".t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurface.withValues(alpha: 0.7)),
),
),
const _AlbumSyncActionButton(),
],
);
}
@ -151,30 +184,59 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
}
}
class _UseWifiForUploadVideosButton extends ConsumerWidget {
const _UseWifiForUploadVideosButton();
class _SettingsSwitchTile extends ConsumerStatefulWidget {
final AppSettingsEnum<bool> appSettingsEnum;
final String titleKey;
final String subtitleKey;
final void Function(bool?)? onChanged;
const _SettingsSwitchTile({
required this.appSettingsEnum,
required this.titleKey,
required this.subtitleKey,
this.onChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
ConsumerState createState() => _SettingsSwitchTileState();
}
class _SettingsSwitchTileState extends ConsumerState<_SettingsSwitchTile> {
late final Stream<bool?> valueStream;
late final StreamSubscription<bool?> subscription;
@override
void initState() {
super.initState();
valueStream = Store.watch(widget.appSettingsEnum.storeKey).asBroadcastStream();
subscription = valueStream.listen((value) {
widget.onChanged?.call(value);
});
}
@override
void dispose() {
subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
"videos".t(context: context),
widget.titleKey.t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
subtitle: Text(widget.subtitleKey.t(context: context), style: context.textTheme.labelLarge),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
initialData: Store.tryGet(widget.appSettingsEnum.storeKey) ?? widget.appSettingsEnum.defaultValue,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.useCellularForUploadVideos, newValue);
await ref.read(appSettingsServiceProvider).setSetting(widget.appSettingsEnum, newValue);
},
);
},
@ -183,34 +245,135 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
}
}
class _UseWifiForUploadVideosButton extends ConsumerWidget {
const _UseWifiForUploadVideosButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return const _SettingsSwitchTile(
appSettingsEnum: AppSettingsEnum.useCellularForUploadVideos,
titleKey: "videos",
subtitleKey: "network_requirement_videos_upload",
);
}
}
class _UseWifiForUploadPhotosButton extends ConsumerWidget {
const _UseWifiForUploadPhotosButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
return ListTile(
title: Text(
"photos".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
value: value,
onChanged: (bool newValue) async {
await ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.useCellularForUploadPhotos, newValue);
},
);
},
),
return const _SettingsSwitchTile(
appSettingsEnum: AppSettingsEnum.useCellularForUploadPhotos,
titleKey: "photos",
subtitleKey: "network_requirement_photos_upload",
);
}
}
class _BackupOnlyWhenChargingButton extends ConsumerWidget {
const _BackupOnlyWhenChargingButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _SettingsSwitchTile(
appSettingsEnum: AppSettingsEnum.backupRequireCharging,
titleKey: "charging",
subtitleKey: "charging_requirement_mobile_backup",
onChanged: (value) {
ref.read(backgroundWorkerFgServiceProvider).configure(requireCharging: value ?? false);
},
);
}
}
class _BackupDelaySlider extends ConsumerStatefulWidget {
const _BackupDelaySlider();
@override
ConsumerState<_BackupDelaySlider> createState() => _BackupDelaySliderState();
}
class _BackupDelaySliderState extends ConsumerState<_BackupDelaySlider> {
late final Stream<int?> valueStream;
late final StreamSubscription<int?> subscription;
late int currentValue;
static int backupDelayToSliderValue(int ms) => switch (ms) {
5 => 0,
30 => 1,
120 => 2,
_ => 3,
};
static int backupDelayToSeconds(int v) => switch (v) {
0 => 5,
1 => 30,
2 => 120,
_ => 600,
};
static String formatBackupDelaySliderValue(int v) => switch (v) {
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
};
@override
void initState() {
super.initState();
final initialValue =
Store.tryGet(AppSettingsEnum.backupTriggerDelay.storeKey) ?? AppSettingsEnum.backupTriggerDelay.defaultValue;
currentValue = backupDelayToSliderValue(initialValue);
valueStream = Store.watch(AppSettingsEnum.backupTriggerDelay.storeKey).asBroadcastStream();
subscription = valueStream.listen((value) {
if (mounted && value != null) {
setState(() {
currentValue = backupDelayToSliderValue(value);
});
}
});
}
@override
void dispose() {
subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
child: Text(
'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
),
Slider(
value: currentValue.toDouble(),
onChanged: (double v) {
setState(() {
currentValue = v.toInt();
});
},
onChangeEnd: (double v) async {
final milliseconds = backupDelayToSeconds(v.toInt());
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.backupTriggerDelay, milliseconds);
},
max: 3.0,
min: 0.0,
divisions: 3,
label: formatBackupDelaySliderValue(currentValue),
),
],
);
}
}

View File

@ -11,10 +11,19 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
class BackgroundWorkerSettings {
final bool requiresCharging;
final int minimumDelaySeconds;
const BackgroundWorkerSettings({required this.requiresCharging, required this.minimumDelaySeconds});
}
@HostApi()
abstract class BackgroundWorkerFgHostApi {
void enable();
void configure(BackgroundWorkerSettings settings);
void disable();
}

View File

@ -2171,4 +2171,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.35.3"
flutter: ">=3.35.4"

View File

@ -6,7 +6,7 @@ version: 1.142.1+3015
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.35.3
flutter: 3.35.4
isar_version: &isar_version 3.1.8

View File

@ -502,6 +502,21 @@ void main() {
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
});
test('should show when asset is merged', () {
final context = ActionButtonContext(
asset: mergedAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
});
});
group('upload button', () {

View File

@ -56,7 +56,7 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
# Flutter SDK
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
ENV FLUTTER_CHANNEL="stable"
ENV FLUTTER_VERSION="3.35.3"
ENV FLUTTER_VERSION="3.35.4"
ENV FLUTTER_HOME=/flutter
ENV PATH=${PATH}:${FLUTTER_HOME}/bin

View File

@ -0,0 +1,24 @@
<script lang="ts">
import { Label, Link, Text } from '@immich/ui';
type Props = {
id: string;
title: string;
version?: string;
versionHref?: string;
class?: string;
};
const { id, title, version, versionHref, class: className }: Props = $props();
</script>
<div class={className}>
<Label size="small" color="primary" for={id}>{title}</Label>
<Text size="small" color="muted" {id}>
{#if versionHref}
<Link external href={versionHref}>{version}</Link>
{:else}
{version}
{/if}
</Text>
</div>

View File

@ -163,7 +163,7 @@
{#if config.storageTemplate.enabled}
<hr />
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
<h3 class="text-base font-medium text-primary">{$t('variables')}</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
@ -180,7 +180,7 @@
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
<div class="my-2 text-sm">
<h4 class="uppercase">{$t('preview')}</h4>
@ -192,7 +192,7 @@
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
>
{#snippet children({ message })}
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
<span class="font-semibold text-primary">{message}</span>
{/snippet}
</FormatMessage>
</p>
@ -200,7 +200,7 @@
<p class="text-sm">
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
{#snippet children({ message })}
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
<code class="text-primary">{message}</code>
{/snippet}
</FormatMessage>
</p>
@ -214,10 +214,7 @@
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
<div class="flex flex-col my-2">
{#if templateOptions}
<label
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
for="preset-select"
>
<label class="font-medium text-primary text-sm" for="preset-select">
{$t('preset')}
</label>
<select
@ -257,7 +254,7 @@
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
<h3 class="text-base font-medium text-primary">{$t('notes')}</h3>
<section class="flex flex-col gap-2">
<p>
<FormatMessage
@ -265,7 +262,7 @@
values={{ job: $t('admin.storage_template_migration_job') }}
>
{#snippet children({ message })}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
<a href={AppRoute.ADMIN_JOBS} class="text-primary">
{message}
</a>
{/snippet}

View File

@ -27,7 +27,7 @@
</div>
<div class="flex gap-[40px]">
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year')}</p>
<p class="uppercase font-medium text-primary">{$t('year')}</p>
<ul>
{#each options.yearOptions as yearFormat, index (index)}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
@ -36,7 +36,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month')}</p>
<p class="uppercase font-medium text-primary">{$t('month')}</p>
<ul>
{#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
@ -45,7 +45,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week')}</p>
<p class="uppercase font-medium text-primary">{$t('week')}</p>
<ul>
{#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
@ -54,7 +54,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day')}</p>
<p class="uppercase font-medium text-primary">{$t('day')}</p>
<ul>
{#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@ -63,7 +63,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour')}</p>
<p class="uppercase font-medium text-primary">{$t('hour')}</p>
<ul>
{#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@ -72,7 +72,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute')}</p>
<p class="uppercase font-medium text-primary">{$t('minute')}</p>
<ul>
{#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@ -81,7 +81,7 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second')}</p>
<p class="uppercase font-medium text-primary">{$t('second')}</p>
<ul>
{#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>

View File

@ -9,7 +9,7 @@
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="flex gap-[50px]">
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename')}</p>
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
<ul>
<li>{`{{filename}}`} - IMG_123</li>
<li>{`{{ext}}`} - jpg</li>
@ -17,14 +17,14 @@
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype')}</p>
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('other')}</p>
<p class="uppercase font-medium text-primary">{$t('other')}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>

View File

@ -48,7 +48,7 @@
<button
type="button"
onclick={() => toggleAlbumGroupCollapsing(group.id)}
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-immich-primary dark:hover:text-immich-dark-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
class="w-full text-start mt-2 pt-2 pe-2 pb-2 rounded-md transition-colors cursor-pointer dark:text-immich-dark-fg hover:text-primary hover:bg-subtle dark:hover:bg-immich-dark-gray"
aria-expanded={!isCollapsed}
>
<Icon icon={mdiChevronRight} size="24" class="inline-block -mt-2.5 transition-all duration-250 {iconRotation}" />

View File

@ -60,7 +60,7 @@
<div class="mt-4">
<p
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-primary"
data-testid="album-name"
title={album.albumName}
>

View File

@ -38,7 +38,7 @@
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
type="text"

View File

@ -64,9 +64,7 @@
<Timeline enableRouting={true} {album} {timelineManager} {assetInteraction}>
<section class="pt-8 md:pt-24 px-2 md:px-0">
<!-- ALBUM TITLE -->
<h1
class="text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary"
>
<h1 class="text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all">
{album.albumName}
</h1>

View File

@ -26,7 +26,7 @@
<table class="mt-2 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center p-2 md:p-5">
{#each sortOptionsMetadata as option, index (index)}

View File

@ -1,6 +1,6 @@
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
@ -18,6 +18,7 @@ type ActionMap = {
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
};
export type Action = {

View File

@ -4,21 +4,36 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updatePerson, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { mdiFaceManProfile } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
person: PersonResponseDto;
onAction?: OnAction;
}
let { asset, person }: Props = $props();
let { asset, person, onAction }: Props = $props();
const handleSelectFeaturePhoto = async () => {
try {
await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
const updatedPerson = await updatePerson({
id: person.id,
personUpdateDto: { featureFaceAssetId: asset.id },
});
person = { ...person, ...updatedPerson };
onAction?.({
type: AssetAction.SET_PERSON_FEATURED_PHOTO,
asset,
person,
});
notificationController.show({ message: $t('feature_photo_updated'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));

View File

@ -208,7 +208,7 @@
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} />
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />

View File

@ -22,6 +22,7 @@
import {
AssetJobName,
AssetTypeEnum,
getAssetInfo,
getAllAlbums,
getStack,
runAssetJobs,
@ -339,6 +340,11 @@
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
asset = { ...asset, people: assetInfo.people };
break;
}
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();

View File

@ -40,8 +40,7 @@
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? $t('edit_location') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
class:hover:text-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
@ -72,7 +71,7 @@
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
onclick={() => (isShowChangeLocation = true)}
title={$t('add_location')}
>

View File

@ -279,8 +279,7 @@
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
title={isOwner ? $t('edit_date') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
class:hover:text-primary={isOwner}
>
<div class="flex gap-4">
<div>
@ -362,10 +361,7 @@
{/if}
</p>
{#if showAssetPath}
<p
class="text-xs opacity-50 break-all pb-2 hover:dark:text-immich-dark-primary hover:text-immich-primary"
transition:slide={{ duration: 250 }}
>
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
{asset.originalPath}
</a>
@ -403,7 +399,7 @@
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
})}"
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:dark:text-immich-dark-primary hover:text-immich-primary"
class="hover:text-primary"
>
{asset.exifInfo.make || ''}
{asset.exifInfo.model || ''}
@ -417,7 +413,7 @@
<a
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}"
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:dark:text-immich-dark-primary hover:text-immich-primary line-clamp-1"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>

View File

@ -63,7 +63,7 @@
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
<div class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary">
<div class="flex items-center gap-4 text-xl font-semibold text-primary">
<span class="flex items-center gap-2">
<Icon {icon} size="1.25em" class="hidden shrink-0 sm:block" />
<span class="uppercase">{title}</span>

View File

@ -11,7 +11,7 @@
{#snippet children({ message })}
<a
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
class="text-immich-primary dark:text-immich-dark-primary"
class="text-primary"
>
{message}
</a>

View File

@ -36,7 +36,7 @@
>
<div>
<div class="flex items-center justify-between gap-4 px-4 py-4">
<h1 class="font-medium text-immich-primary dark:text-immich-dark-primary">
<h1 class="font-medium text-primary">
🚨 {$t('error_title')}
</h1>
<div class="flex justify-end">

View File

@ -36,10 +36,10 @@
{#if title || icon}
<div class="flex gap-2 items-center justify-center w-fit">
{#if icon}
<Icon {icon} size="30" class="text-immich-primary dark:text-immich-dark-primary" />
<Icon {icon} size="30" class="text-primary" />
{/if}
{#if title}
<p class="uppercase text-xl text-immich-primary dark:text-immich-dark-primary">
<p class="uppercase text-xl text-primary">
{title}
</p>
{/if}

View File

@ -1,16 +1,16 @@
<script lang="ts">
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import { user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
</script>
<div class="gap-4">
<ImmichLogo noText class="h-[100px] mb-2" />
<p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
<p class="font-medium mb-6 text-6xl text-primary">
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
</p>
<p class="text-3xl pb-6 font-light">

View File

@ -7,7 +7,7 @@
</svelte:head>
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
<h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1>
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
{#if page.error?.message}
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
{/if}

View File

@ -72,8 +72,8 @@
class="relative h-dvh overflow-hidden px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height) sm:px-12 md:px-24 lg:px-40"
>
<div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>
<div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
<div class="text-2xl font-bold text-primary">{$t('password_required')}</div>
<div class="mt-4 text-lg text-primary">
{$t('sharing_enter_password')}
</div>
<div class="mt-4">

View File

@ -46,38 +46,38 @@
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<div class="flex place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<p class="uppercase">{$t('photos')}</p>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.photos}</span
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span class="text-primary"
>{stats.photos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<div class="flex place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<p class="uppercase">{$t('videos')}</p>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{stats.videos}</span
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span class="text-primary"
>{stats.videos}</span
>
</div>
</div>
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<div class="flex place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<p class="uppercase">{$t('storage')}</p>
</div>
<div class="relative flex text-center font-mono text-2xl font-semibold">
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span
class="text-immich-primary dark:text-immich-dark-primary">{statsUsage}</span
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span class="text-primary"
>{statsUsage}</span
>
<span class="my-auto ms-2 text-center text-base font-light text-gray-400">{statsUsageUnit}</span>
</div>
@ -90,7 +90,7 @@
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('user_usage_detail')}</p>
<table class="mt-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-1/4 text-center text-sm font-medium">{$t('user')}</th>
@ -116,7 +116,7 @@
{#if user.quotaSizeInBytes !== null}
/ {getByteUnitString(user.quotaSizeInBytes, $locale, 0)}
{/if}
<span class="text-immich-primary dark:text-immich-dark-primary">
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
style: 'percent',

View File

@ -1,6 +1,6 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { DateTime } from 'luxon';
import ChangeDate from './change-date.svelte';
@ -30,6 +30,13 @@ describe('ChangeDate component', () => {
vi.resetAllMocks();
});
afterAll(async () => {
await waitFor(() => {
// check that bits-ui body scroll-lock class is gone
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
test('should render correct values', () => {
render(ChangeDate, { initialDate, initialTimeZone, onCancel, onConfirm });
expect(getDateInput().value).toBe('2024-01-01T00:00');

View File

@ -367,11 +367,7 @@
>
{#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
{#if useLocationPin}
<Icon
icon={mdiMapMarker}
size="50px"
class="dark:text-immich-dark-primary text-immich-primary -translate-y-[50%]"
/>
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id)}

View File

@ -55,7 +55,7 @@
</div>
</div>
<div>
<p class="text-center text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
<p class="text-center text-lg font-medium text-primary">
{$user.name}
</p>
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{$user.email}</p>
@ -107,7 +107,7 @@
<button
type="button"
class="text-center mt-4 underline text-xs text-immich-primary dark:text-immich-dark-primary"
class="text-center mt-4 underline text-xs text-primary"
onclick={async () => {
onClose();
if (info) {

View File

@ -10,7 +10,7 @@
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<Icon icon={mdiAccount} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>
</div>

View File

@ -14,7 +14,7 @@
</script>
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center my-6">
<Icon icon={mdiPartyPopper} class="text-immich-primary dark:text-immich-dark-primary" size="96" />
<Icon icon={mdiPartyPopper} class="text-primary" size="96" />
<p class="text-4xl mt-8 font-bold">{$t('purchase_activated_title')}</p>
<p class="text-lg mt-6">{$t('purchase_activated_subtitle')}</p>

View File

@ -10,7 +10,7 @@
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<Icon icon={mdiServer} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>
</div>

View File

@ -102,7 +102,7 @@
<button
id={getId(0)}
type="button"
class="rounded-lg p-2 font-semibold text-immich-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25 dark:text-immich-dark-primary"
class="rounded-lg p-2 font-semibold text-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25"
role="option"
onclick={() => handleClearAll()}
tabindex="-1"

View File

@ -78,9 +78,9 @@
<div>
<div class="flex gap-2 place-items-center">
{#if icon}
<Icon {icon} class="text-immich-primary dark:text-immich-dark-primary" size="24" aria-hidden />
<Icon {icon} class="text-primary" size="24" aria-hidden />
{/if}
<h2 class="font-medium text-immich-primary dark:text-immich-dark-primary">
<h2 class="font-medium text-primary">
{title}
</h2>
</div>

View File

@ -16,13 +16,9 @@
<div class="mt-8 flex justify-between gap-2">
<div class="left">
{#if showResetToDefault}
<button
type="button"
onclick={() => onReset({ default: true })}
class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75"
<Button variant="ghost" shape="round" size="small" onclick={() => onReset({ default: true })}
>{$t('reset_to_default')}</Button
>
{$t('reset_to_default')}
</button>
{/if}
</div>

View File

@ -31,7 +31,7 @@
<div class="mb-4 w-full">
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select">
<label class="font-medium text-primary text-sm" for="{name}-select">
{label}
</label>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
interface Props {
title: string;
@ -31,7 +31,7 @@
<div class="grid grid-cols-2">
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
<label class="font-medium text-primary text-sm" for={title}>
{title}
</label>
{#if isEdited}

View File

@ -29,7 +29,7 @@
<div class="flex place-items-center justify-between">
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={title}>
<label class="font-medium text-primary text-sm" for={title}>
{title}
</label>
{#if isEdited}

View File

@ -81,9 +81,7 @@
<div class="mb-4 w-full">
<div class="flex place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm min-h-6 uppercase" for={label}
>{label}</label
>
<label class="font-medium text-primary text-sm min-h-6 uppercase" for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}

View File

@ -40,9 +40,7 @@
<div class="mb-4 w-full">
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="{name}-select"
>{label}</label
>
<label class="font-medium text-primary text-sm" for="{name}-select">{label}</label>
{#if isEdited}
<div

View File

@ -35,7 +35,7 @@
<div class="flex place-items-center justify-between">
<div class="me-2">
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={sliderId}>
<label class="font-medium text-primary text-sm" for={sliderId}>
{title}
</label>
{#if isEdited}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
interface Props {
value: string;
@ -31,7 +31,7 @@
<div class="mb-4 w-full">
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={label}>{label}</label>
<label class="font-medium text-primary text-sm" for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}

View File

@ -38,7 +38,7 @@
{#if showSettingDescription}
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={$t('language')}>
<label class="font-medium text-primary text-sm" for={$t('language')}>
{$t('language')}
</label>
</div>

View File

@ -96,17 +96,13 @@
<div class="h-6 w-6">
<ImmichLogo noText class="h-[24px]" />
</div>
<p class="flex text-immich-primary dark:text-immich-dark-primary font-medium">
<p class="flex text-primary font-medium">
{$t('purchase_button_buy_immich')}
</p>
</div>
<div>
<Icon
icon={mdiInformationOutline}
class="hidden sidebar:flex text-immich-primary dark:text-immich-dark-primary font-medium"
size="18"
/>
<Icon icon={mdiInformationOutline} class="hidden sidebar:flex text-primary font-medium" size="18" />
</div>
</div>
</button>
@ -142,7 +138,7 @@
/>
</div>
<h1 class="text-lg font-medium my-3 dark:text-immich-dark-primary text-immich-primary">
<h1 class="text-lg font-medium my-3 text-primary">
{$t('purchase_panel_title')}
</h1>

View File

@ -57,9 +57,7 @@
draggable="false"
aria-current={isSelected ? 'page' : undefined}
class="flex w-full place-items-center gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
: ''}"
{isSelected ? 'bg-immich-primary/10 text-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10' : ''}"
>
<div class="flex w-full place-items-center gap-4 ps-5 overflow-hidden truncate">
<Icon {icon} size="1.5em" class="shrink-0" flipped={flippedLogo} aria-hidden />

View File

@ -1,11 +1,22 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { defaultLang, langs, Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { ThemeSwitcher } from '@immich/ui';
import { get } from 'svelte/store';
const handleToggleTheme = () => {
if (themeManager.theme.system) {
return;
}
themeManager.toggleTheme();
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 't', alt: true }, onShortcut: () => handleToggleTheme() }} />
{#if !themeManager.theme.system}
{#await langs
.find((item) => item.code === get(lang))

View File

@ -51,9 +51,7 @@
/>
</li>
{#each parents as parent (parent)}
<li
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
>
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<a class="underline hover:font-semibold whitespace-pre-wrap" href={getLink(parent.path)}>
{parent.value}
@ -61,9 +59,7 @@
</li>
{/each}
<li
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
>
<li class="flex gap-2 items-center font-mono text-sm text-nowrap text-primary">
<Icon icon={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size="16" aria-hidden />
<p class="cursor-default whitespace-pre-wrap">{node.value}</p>
</li>

View File

@ -23,7 +23,7 @@
title={item.value}
type="button"
>
<Icon {icon} class="text-immich-primary dark:text-immich-dark-primary" size="64" />
<Icon {icon} class="text-primary" size="64" />
<p class="text-sm dark:text-gray-200 text-nowrap text-ellipsis overflow-clip w-full whitespace-pre-wrap">
{item.value}
</p>

View File

@ -26,7 +26,7 @@
<a
href={getLink(node.path)}
title={node.value}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
class={`flex grow place-items-center ps-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
>
{#if node.size > 0}
@ -37,7 +37,7 @@
<div class={node.size === 0 ? 'ml-[1.5em] ' : ''}>
<Icon
icon={isActive ? icons.active : icons.default}
class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
class={isActive ? 'text-primary' : 'text-gray-400'}
color={node.color}
size="20"
/>

View File

@ -61,9 +61,7 @@
</div>
<div class="text-sm pb-2">
<p
class="flex place-items-center gap-2 text-immich-primary dark:text-immich-dark-primary break-all uppercase"
>
<p class="flex place-items-center gap-2 text-primary break-all uppercase">
{#if link.type === SharedLinkType.Album}
{link.album?.albumName}
{:else if link.type === SharedLinkType.Individual}

View File

@ -38,9 +38,7 @@
<ControlAppBar onClose={clearSelect} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
{#snippet leading()}
<div
class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-immich-primary dark:text-immich-dark-primary'}"
>
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
<p class="block sm:hidden">{assets.length}</p>
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
</div>

View File

@ -12,6 +12,7 @@
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute, AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
@ -223,45 +224,33 @@
complete.then(completeNav, completeNav);
});
const hmrSupport = () => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading timeline is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
if (import.meta && import.meta.hot) {
const afterApdate = (payload: UpdatePayload) => {
const assetGridUpdate = payload.updates.some(
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
);
const handleAfterUpdate = (payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some(
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
);
if (assetGridUpdate) {
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
scrollToTop();
}
showSkeleton = false;
}, 500);
if (timelineUpdate) {
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
scrollToTop();
}
};
import.meta.hot?.on('vite:afterUpdate', afterApdate);
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
if (assetGridUpdate) {
timelineManager.destroy();
}
});
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
showSkeleton = false;
}, 500);
}
};
const handleBeforeUpdate = (payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
if (timelineUpdate) {
timelineManager.destroy();
}
return () => void 0;
};
const updateIsScrolling = () => (timelineManager.scrolling = true);
@ -287,10 +276,6 @@
if (!enableRouting) {
showSkeleton = false;
}
const disposeHmr = hmrSupport();
return () => {
disposeHmr();
};
});
const getMaxScrollPercent = () => {
@ -833,6 +818,8 @@
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}

View File

@ -31,7 +31,7 @@
</script>
<div class="flex w-full flex-row">
<div class="hidden items-center justify-center pe-2 text-immich-primary dark:text-immich-dark-primary sm:flex">
<div class="hidden items-center justify-center pe-2 text-primary sm:flex">
{#if device.deviceOS === 'Android'}
<Icon icon={mdiAndroid} size="40" />
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'macOS'}

View File

@ -56,7 +56,7 @@
<section class="my-4">
{#if currentDevice}
<div class="mb-6">
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
{$t('current_device')}
</h3>
<DeviceCard device={currentDevice} />
@ -64,7 +64,7 @@
{/if}
{#if otherDevices.length > 0}
<div class="mb-6">
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
{$t('other_devices')}
</h3>
{#each otherDevices as device, index (device.id)}
@ -74,7 +74,7 @@
{/if}
{/each}
</div>
<h3 class="uppercase mb-2 text-xs font-medium text-immich-primary dark:text-immich-dark-primary">
<h3 class="uppercase mb-2 text-xs font-medium text-primary">
{$t('log_out_all_devices')}
</h3>
<div class="flex justify-end">

View File

@ -101,7 +101,7 @@
{#if keys.length > 0}
<table class="w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-1/4 text-center text-sm font-medium">{$t('name')}</th>

View File

@ -122,10 +122,10 @@
<div
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
>
<Icon icon={mdiKey} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
<Icon icon={mdiKey} size="56" class="text-primary" />
<div>
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">
<p class="text-primary font-semibold text-lg">
{$t('purchase_server_title')}
</p>
@ -154,10 +154,10 @@
<div
class="bg-gray-50 border border-immich-dark-primary/20 dark:bg-immich-dark-primary/15 p-6 pe-12 rounded-xl flex place-content-center gap-4"
>
<Icon icon={mdiKey} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
<Icon icon={mdiKey} size="56" class="text-primary" />
<div>
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">
<p class="text-primary font-semibold text-lg">
{$t('purchase_individual_title')}
</p>
{#if $user.license?.activatedAt}

View File

@ -71,7 +71,7 @@
<div class="overflow-x-auto">
<table class="w-full text-start mt-4">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center text-sm font-medium text-center">
<th class="w-1/4">{$t('view_name')}</th>
@ -94,9 +94,7 @@
</div>
<div class="overflow-x-auto">
<table class="w-full text-start mt-4">
<thead
class="mb-4 flex h-12 w-full rounded-md border text-immich-primary dark:border-immich-dark-gray bg-subtle dark:text-immich-dark-primary"
>
<thead class="mb-4 flex h-12 w-full rounded-md border text-primary dark:border-immich-dark-gray bg-subtle">
<tr class="flex w-full place-items-center text-sm font-medium text-center">
<th class="w-1/2">{$t('owned')}</th>
<th class="w-1/2">{$t('shared')}</th>

View File

@ -16,7 +16,7 @@
{#each links as link (link.href)}
<a href={link.href} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4">
<span><Icon icon={link.icon} class="text-immich-primary dark:text-immich-dark-primary" size="24" /> </span>
<span><Icon icon={link.icon} class="text-primary" size="24" /> </span>
{link.label}
</a>
{/each}

View File

@ -14,6 +14,7 @@ export enum AssetAction {
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
}
export enum AppRoute {

View File

@ -121,10 +121,10 @@
onclick={() => !renderedOption.disabled && handleSelectOption(option)}
>
{#if isEqual(selectedOption, option)}
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<Icon icon={mdiCheck} />
</div>
<p class="justify-self-start text-immich-primary dark:text-immich-dark-primary">
<p class="justify-self-start text-primary">
{renderedOption.title}
</p>
{:else}

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { UpdatePayload } from 'vite';
type Props = {
onBeforeUpdate?: (payload: UpdatePayload) => void;
onAfterUpdate?: (payload: UpdatePayload) => void;
};
let { onBeforeUpdate, onAfterUpdate }: Props = $props();
const unsubscribes: (() => void)[] = [];
onMount(() => {
const hot = import.meta.hot;
if (!hot) {
return;
}
if (onBeforeUpdate) {
hot.on('vite:beforeUpdate', onBeforeUpdate);
unsubscribes.push(() => hot.off('vite:beforeUpdate', onBeforeUpdate));
}
if (onAfterUpdate) {
hot.on('vite:afterUpdate', onAfterUpdate);
unsubscribes.push(() => hot.off('vite:afterUpdate', onAfterUpdate));
}
});
onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
});
</script>

View File

@ -57,7 +57,7 @@
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<fieldset
class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
class="text-primary w-fit cursor-default"
onmouseleave={() => setHoverRating(0)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
@ -114,7 +114,7 @@
ratingSelection = 0;
handleSelect(ratingSelection);
}}
class="cursor-pointer text-xs text-immich-primary dark:text-immich-dark-primary"
class="cursor-pointer text-xs text-primary"
>
{$t('rating_clear')}
</button>

View File

@ -140,7 +140,7 @@
<button
type="button"
onclick={() => handleRemoveUser(user)}
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
class="text-sm font-medium text-primary transition-colors hover:text-immich-primary/75"
>{$t('leave')}</button
>
{/if}

View File

@ -14,7 +14,7 @@
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
<ModalBody>
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<p class="text-sm dark:text-immich-dark-fg">
{$t('api_key_description')}
</p>

View File

@ -20,10 +20,7 @@
<div>
<a href="https://{info.version}.archive.immich.app/docs/overview/introduction" target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="documentation-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
@ -32,10 +29,7 @@
<div>
<a href="https://github.com/immich-app/immich/" target="_blank" rel="noreferrer">
<Icon icon={mdiGithub} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
@ -44,10 +38,7 @@
<div>
<a href="https://discord.immich.app" target="_blank" rel="noreferrer">
<Icon icon={siDiscord} class="inline-block" size="1.5em" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('discord')}
</p>
</a>
@ -56,10 +47,7 @@
<div>
<a href="https://github.com/immich-app/immich/issues/new/choose" target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>
@ -75,10 +63,7 @@
<div>
<a href={info.thirdPartyDocumentationUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="documentation-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
@ -89,10 +74,7 @@
<div>
<a href={info.thirdPartySourceUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiGit} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
@ -103,10 +85,7 @@
<div>
<a href={info.thirdPartySupportUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiFaceAgent} class="inline-block" size="1.5em" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('support')}
</p>
</a>
@ -117,10 +96,7 @@
<div>
<a href={info.thirdPartyBugFeatureUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm underline inline-block"
id="github-label"
>
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>

View File

@ -37,7 +37,7 @@
<Modal title={$t('set_date_of_birth')} icon={mdiCake} {onClose} size="small">
<ModalBody>
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<p class="text-sm dark:text-immich-dark-fg">
{$t('birthdate_set_description')}
</p>

View File

@ -1,8 +1,8 @@
<script lang="ts">
import ServerAboutItem from '$lib/components/ServerAboutItem.svelte';
import { locale } from '$lib/stores/preferences.store';
import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiAlert } from '@mdi/js';
import { Alert, Label, Modal, ModalBody } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@ -17,158 +17,61 @@
<Modal title={$t('about')} {onClose}>
<ModalBody>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary">
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>Immich</label
>
<div>
<a
href={info.versionUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="version-desc"
>
{info.version}
</a>
</div>
</div>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-4">
{#if info.sourceRef === 'main' && info.repository === 'immich-app/immich'}
<Alert color="warning" title={$t('main_branch_warning')} class="col-span-full" size="small" />
{/if}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
>ExifTool</label
>
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
{info.exiftool}
</p>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="nodejs-desc"
>Node.js</label
>
<p class="immich-form-label pb-2 text-sm" id="nodejs-desc">
{info.nodejs}
</p>
</div>
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="vips-desc"
>Libvips</label
>
<p class="immich-form-label pb-2 text-sm" id="vips-desc">
{info.libvips}
</p>
</div>
<div class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="imagemagick-desc"
>ImageMagick</label
>
<p class="immich-form-label pb-2 text-sm" id="imagemagick-desc">
{info.imagemagick}
</p>
</div>
<div class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="ffmpeg-desc"
>FFmpeg</label
>
<p class="immich-form-label pb-2 text-sm" id="ffmpeg-desc">
{info.ffmpeg}
</p>
</div>
<ServerAboutItem id="immich" title="Immich" version={info.version} versionHref={info.versionUrl} />
<ServerAboutItem id="exif" title="ExifTool" version={info.exiftool} />
<ServerAboutItem id="nodejs" title="Node.js" version={info.nodejs} />
<ServerAboutItem id="libvips" title="Libvips" version={info.libvips} />
<ServerAboutItem
id="imagemagick"
title="ImageMagick"
version={info.imagemagick}
class={(info.imagemagick?.length || 0) > 10 ? 'col-span-2' : ''}
/>
<ServerAboutItem
id="ffmpeg"
title="FFmpeg"
version={info.ffmpeg}
class={(info.ffmpeg?.length || 0) > 10 ? 'col-span-2' : ''}
/>
{#if info.repository && info.repositoryUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>{$t('repository')}</label
>
<div>
<a
href={info.repositoryUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="version-desc"
>
{info.repository}
</a>
</div>
</div>
<ServerAboutItem
id="repository"
title={$t('repository')}
version={info.repository}
versionHref={info.repositoryUrl}
/>
{/if}
{#if info.sourceRef && info.sourceCommit && info.sourceUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="git-desc"
>{$t('source')}</label
>
<div>
<a
href={info.sourceUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="git-desc"
>
{info.sourceRef}@{info.sourceCommit.slice(0, 9)}
</a>
</div>
</div>
<ServerAboutItem
id="source"
title={$t('source')}
version="{info.sourceRef}@{info.sourceCommit.slice(0, 9)}"
versionHref={info.sourceUrl}
/>
{/if}
{#if info.build && info.buildUrl}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-desc"
>{$t('build')}</label
>
<div>
<a
href={info.buildUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="build-desc"
>
{info.build}
</a>
</div>
</div>
<ServerAboutItem id="build" title={$t('build')} version={info.build} versionHref={info.buildUrl} />
{/if}
{#if info.buildImage && info.buildImage}
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="build-image-desc"
>{$t('build_image')}</label
>
<div>
<a
href={info.buildImageUrl}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="build-image-desc"
>
{info.buildImage}
</a>
</div>
</div>
{/if}
{#if info.sourceRef === 'main' && info.repository === 'immich-app/immich'}
<div class="col-span-full p-4 flex gap-1">
<Icon icon={mdiAlert} size="2em" color="#ffcc4d" />
<p class="immich-form-label text-sm" id="main-warning">
{$t('main_branch_warning')}
</p>
</div>
<ServerAboutItem
id="build-image"
title={$t('build_image')}
version={info.buildImage}
versionHref={info.buildImageUrl}
/>
{/if}
<div class="col-span-full">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-history"
>{$t('version_history')}</label
>
<Label size="small" color="primary" for="version-history">{$t('version_history')}</Label>
<ul id="version-history" class="list-none">
{#each versions.slice(0, 5) as item (item.id)}
{@const createdAt = DateTime.fromISO(item.createdAt)}

View File

@ -146,7 +146,7 @@
{:else}
<div class="text-sm">
{$t('public_album')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.album?.albumName}</span>
<span class="text-primary">{editingLink.album?.albumName}</span>
</div>
{/if}
{/if}
@ -157,7 +157,7 @@
{:else}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-immich-primary dark:text-immich-dark-primary">{editingLink.description || ''}</span>
<span class="text-primary">{editingLink.description || ''}</span>
</div>
{/if}
{/if}

View File

@ -75,7 +75,7 @@
<Modal size="small" title={$t('slideshow_settings')} onClose={() => onClose()}>
<ModalBody>
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
<div class="flex flex-col gap-4 text-primary">
<SettingDropdown
title={$t('direction')}
options={Object.values(navigationOptions)}

View File

@ -38,7 +38,7 @@
<Modal size="small" title={$t('create_tag')} icon={mdiTag} {onClose}>
<ModalBody>
<div class="text-immich-primary dark:text-immich-dark-primary">
<div class="text-primary">
<p class="text-sm dark:text-immich-dark-fg">
{$t('create_tag_description')}
</p>

View File

@ -101,7 +101,7 @@
<p>
{$t('admin.note_apply_storage_label_previous_assets')}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
<a href={AppRoute.ADMIN_JOBS} class="text-primary">
{$t('admin.storage_template_migration_job')}
</a>
</p>

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