diff --git a/i18n/en.json b/i18n/en.json
index b142dc7fca..22354c2f8b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1231,6 +1231,34 @@
"link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account",
"list": "List",
+ "live_wallpaper_setting_manage": "Configure live wallpaper",
+ "live_wallpaper_setting_open_picker": "Open system wallpaper picker",
+ "live_wallpaper_setting_open_picker_failed": "Couldn't open wallpaper picker",
+ "live_wallpaper_setting_open_picker_success": "Opening wallpaper picker…",
+ "live_wallpaper_setting_title": "Live wallpaper",
+ "live_wallpaper_setup_allow_cellular": "Allow cellular data",
+ "live_wallpaper_setup_allow_cellular_subtitle": "Fetch new photos even when not on Wi‑Fi",
+ "live_wallpaper_setup_disable_button": "Disable live wallpaper",
+ "live_wallpaper_setup_disabled_message": "Live wallpaper disabled",
+ "live_wallpaper_setup_enable_button": "Set live wallpaper",
+ "live_wallpaper_setup_enabled_message": "Live wallpaper updated",
+ "live_wallpaper_setup_error_loading_people": "Couldn't load your people",
+ "live_wallpaper_setup_minutes": "minutes",
+ "live_wallpaper_setup_no_results": "No people match your search",
+ "live_wallpaper_setup_open_picker": "Set from system settings",
+ "live_wallpaper_setup_refresh_button": "Refresh now",
+ "live_wallpaper_setup_refresh_failed": "Couldn't request refresh ({error})",
+ "live_wallpaper_setup_refresh_triggered": "Wallpaper refresh requested",
+ "live_wallpaper_setup_rotation_label": "Rotate every",
+ "live_wallpaper_setup_search_hint": "Search people",
+ "live_wallpaper_setup_select_people_first": "Select at least one person to continue",
+ "live_wallpaper_setup_title": "Live wallpaper",
+ "live_wallpaper_setup_unknown_person": "Unnamed",
+ "live_wallpaper_setup_update_button": "Update wallpaper",
+ "live_wallpaper_status_error": "Wallpaper error: {error}",
+ "live_wallpaper_status_error_generic": "Wallpaper error. Try again shortly.",
+ "live_wallpaper_status_not_supported": "Live wallpaper isn't supported on this device",
+ "live_wallpaper_status_syncing": "Syncing wallpaper settings…",
"loading": "Loading",
"loading_search_results_failed": "Loading search results failed",
"local": "Local",
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index c6e04e5a10..8f40d988d7 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -19,6 +19,12 @@
+
+
+
+
@@ -38,6 +44,23 @@
android:exported="false"
android:foregroundServiceType="dataSync|shortService" />
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
index 034f5ee72e..eee56d96f3 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt
@@ -14,6 +14,8 @@ import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
+import app.alextran.immich.wallpaper.WallpaperHostApi
+import app.alextran.immich.wallpaper.WallpaperHostApiImpl
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -38,6 +40,7 @@ class MainActivity : FlutterFragmentActivity() {
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
+ WallpaperHostApi.setUp(messenger, WallpaperHostApiImpl(ctx))
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt
new file mode 100644
index 0000000000..067d0d84f2
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperService.kt
@@ -0,0 +1,321 @@
+package app.alextran.immich.wallpaper
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.net.ConnectivityManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.service.wallpaper.WallpaperService
+import android.util.Log
+import android.view.SurfaceHolder
+import androidx.core.content.getSystemService
+import app.alextran.immich.widget.ImmichAPI
+import app.alextran.immich.widget.model.SearchFilters
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import kotlin.math.max
+
+class ImmichWallpaperService : WallpaperService() {
+ override fun onCreateEngine(): Engine {
+ return ImmichWallpaperEngine()
+ }
+
+ private inner class ImmichWallpaperEngine : Engine(), SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private val appContext: Context = this@ImmichWallpaperService.applicationContext
+
+ private val store = WallpaperPreferencesStore(appContext)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val handler = Handler(Looper.getMainLooper())
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+ private val previewFile = File(appContext.filesDir, "immich_wallpaper_preview.jpg")
+
+ private var isVisibleToUser = false
+ private var lastRefreshToken = store.getRefreshToken()
+ private var refreshJob: Job? = null
+
+ private val refreshRunnable = Runnable { refreshWallpaper() }
+
+ override fun onCreate(surfaceHolder: SurfaceHolder) {
+ super.onCreate(surfaceHolder)
+ setTouchEventsEnabled(false)
+ store.registerListener(this)
+ refreshWallpaper(force = true)
+ }
+
+ override fun onDestroy() {
+ handler.removeCallbacks(refreshRunnable)
+ store.unregisterListener(this)
+ refreshJob?.cancel()
+ refreshJob = null
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onVisibilityChanged(visible: Boolean) {
+ val wasVisible = isVisibleToUser
+ isVisibleToUser = visible
+
+ if (visible) {
+ val preferences = store.getPreferences()
+ if (preferences?.rotationMode == "lockUnlock" && !wasVisible) {
+ // Only refresh for lock/unlock mode when becoming visible after being hidden
+ Log.d(TAG, "Refreshing wallpaper on unlock (lockUnlock mode)")
+ refreshWallpaper(force = true)
+ } else if (preferences?.rotationMode != "lockUnlock") {
+ // For other modes, just resume regular scheduling without forcing refresh
+ Log.d(TAG, "Resuming wallpaper rotation (${preferences?.rotationMode} mode)")
+ refreshWallpaper(force = false)
+ }
+ } else {
+ Log.d(TAG, "Wallpaper service hidden")
+ handler.removeCallbacks(refreshRunnable)
+ refreshJob?.cancel()
+ refreshJob = null
+ }
+ }
+
+ override fun onSurfaceDestroyed(holder: SurfaceHolder) {
+ handler.removeCallbacks(refreshRunnable)
+ refreshJob?.cancel()
+ refreshJob = null
+ super.onSurfaceDestroyed(holder)
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
+ when (key) {
+ KEY_ENABLED, KEY_ROTATION_MINUTES, KEY_ROTATION_MODE, KEY_PERSON_IDS, KEY_ALLOW_CELLULAR -> refreshWallpaper(force = true)
+ KEY_REFRESH_TOKEN -> {
+ val nextToken = store.getRefreshToken()
+ if (nextToken != lastRefreshToken) {
+ lastRefreshToken = nextToken
+ refreshWallpaper(force = true)
+ }
+ }
+ }
+ }
+
+ private fun refreshWallpaper(force: Boolean = false) {
+ if (!isVisibleToUser && !force) {
+ return
+ }
+
+ val preferences = store.getPreferences()
+ if (preferences == null || !preferences.enabled) {
+ handler.removeCallbacks(refreshRunnable)
+ return
+ }
+
+ // Check if we should fetch a new image or just show cached
+ val shouldFetchNew = force || shouldFetchNewImage(preferences)
+ Log.d(TAG, "refreshWallpaper: force=$force, shouldFetchNew=$shouldFetchNew, mode=${preferences.rotationMode}")
+
+ refreshJob?.cancel()
+ refreshJob = scope.launch {
+ val cached = withContext(Dispatchers.IO) { loadCachedBitmap() }
+ if (cached != null) {
+ drawBitmap(WallpaperImageResult(cached))
+ cached.recycle()
+ }
+
+ // Only fetch new image if needed
+ if (!shouldFetchNew) {
+ Log.d(TAG, "Using cached image, not fetching new wallpaper")
+ scheduleNext(preferences)
+ return@launch
+ }
+
+ if (!preferences.allowCellularData && isActiveNetworkMetered()) {
+ store.setLastError("Waiting for Wi‑Fi to download wallpaper")
+ scheduleNext(preferences)
+ return@launch
+ }
+
+ try {
+ Log.d(TAG, "Fetching new wallpaper image")
+ val result = withContext(Dispatchers.IO) { fetchNext(preferences) }
+ if (result != null) {
+ drawBitmap(result)
+ store.setLastError(null)
+ result.bitmap.recycle()
+ }
+ } catch (error: Exception) {
+ Log.e(TAG, "Failed to update wallpaper", error)
+ store.setLastError(error.message ?: "Unknown error")
+ } finally {
+ scheduleNext(preferences)
+ }
+ }
+ }
+
+ private fun scheduleNext(preferences: WallpaperPreferencesMessage) {
+ handler.removeCallbacks(refreshRunnable)
+ if (!isVisibleToUser) {
+ Log.d(TAG, "Not scheduling next refresh - service not visible")
+ return
+ }
+
+ // Don't schedule for lock/unlock mode - it will be handled by visibility changes
+ if (preferences.rotationMode == "lockUnlock") {
+ Log.d(TAG, "Not scheduling next refresh - using lockUnlock mode")
+ return
+ }
+
+ val delayMs = when (preferences.rotationMode) {
+ "minutes" -> preferences.rotationMinutes * 60_000L
+ "hours" -> preferences.rotationMinutes * 60_000L * 60L
+ "daily" -> 24L * 60L * 60L * 1000L
+ else -> max(preferences.rotationMinutes, 1L) * 60_000L
+ }
+
+ Log.d(TAG, "Scheduling next wallpaper refresh in ${delayMs / 1000}s (mode: ${preferences.rotationMode})")
+ handler.postDelayed(refreshRunnable, delayMs)
+ }
+
+ private fun shouldFetchNewImage(preferences: WallpaperPreferencesMessage): Boolean {
+ // Always fetch for lock/unlock mode
+ if (preferences.rotationMode == "lockUnlock") {
+ return true
+ }
+
+ val lastRefreshAt = store.getLastRefreshAt()
+ if (lastRefreshAt == 0L) {
+ // No previous refresh, fetch new image
+ return true
+ }
+
+ val currentTime = System.currentTimeMillis()
+ val timeSinceLastRefresh = currentTime - lastRefreshAt
+
+ val rotationIntervalMs = when (preferences.rotationMode) {
+ "minutes" -> preferences.rotationMinutes * 60_000L
+ "hours" -> preferences.rotationMinutes * 60_000L * 60L
+ "daily" -> 24L * 60L * 60L * 1000L
+ else -> max(preferences.rotationMinutes, 1L) * 60_000L
+ }
+
+ Log.d(TAG, "Time since last refresh: ${timeSinceLastRefresh / 1000}s, interval: ${rotationIntervalMs / 1000}s")
+ return timeSinceLastRefresh >= rotationIntervalMs
+ }
+
+ private fun isActiveNetworkMetered(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return false
+ }
+
+ val connectivity = appContext.getSystemService()
+ return connectivity?.isActiveNetworkMetered == true
+ }
+
+ private fun cacheBitmap(bitmap: Bitmap) {
+ runCatching {
+ FileOutputStream(previewFile).use { output ->
+ if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 85, output)) {
+ throw IOException("Failed to compress bitmap")
+ }
+ output.flush()
+ }
+ }.onFailure { error ->
+ Log.w(TAG, "Failed to cache wallpaper preview", error)
+ }
+ }
+
+ private fun loadCachedBitmap(): Bitmap? {
+ if (!previewFile.exists() || previewFile.length() == 0L) {
+ return null
+ }
+
+ return runCatching { BitmapFactory.decodeFile(previewFile.absolutePath) }.getOrNull()
+ }
+
+ private suspend fun fetchNext(preferences: WallpaperPreferencesMessage): WallpaperImageResult? {
+ val config = ImmichAPI.getServerConfig(appContext)
+ ?: throw IllegalStateException("Open the app to link your server first")
+
+ val api = ImmichAPI(config)
+
+ if (preferences.personIds.isNotEmpty()) {
+ val shuffledIds = preferences.personIds.shuffled()
+ for (personId in shuffledIds) {
+ val result = fetchForPerson(api, personId)
+ if (result != null) {
+ return result
+ }
+ }
+ throw IllegalStateException("Couldn't find any photos for the selected people")
+ }
+
+ return fetchForPerson(api, null)
+ ?: throw IllegalStateException("Couldn't find any photos in your library yet")
+ }
+
+ private suspend fun fetchForPerson(api: ImmichAPI, personId: String?): WallpaperImageResult? {
+ val filters = SearchFilters()
+ if (personId != null) {
+ filters.personIds = listOf(personId)
+ filters.withPeople = true
+ }
+
+ val assets = api.fetchSearchResults(filters)
+ if (assets.isEmpty()) {
+ return null
+ }
+
+ val asset = assets.random()
+ val bitmap = api.fetchImage(asset)
+ cacheBitmap(bitmap)
+ store.setLastAssetId(asset.id)
+ return WallpaperImageResult(bitmap)
+ }
+
+ private fun drawBitmap(result: WallpaperImageResult) {
+ val holder = surfaceHolder ?: return
+ var canvas: Canvas? = null
+ try {
+ canvas = holder.lockCanvas()
+ if (canvas != null) {
+ val bitmap = result.bitmap
+ canvas.drawColor(Color.BLACK)
+
+ val scale = max(
+ canvas.width.toFloat() / bitmap.width,
+ canvas.height.toFloat() / bitmap.height
+ )
+ val dx = (canvas.width - bitmap.width * scale) / 2f
+ val dy = (canvas.height - bitmap.height * scale) / 2f
+ val matrix = Matrix().apply {
+ postScale(scale, scale)
+ postTranslate(dx, dy)
+ }
+
+ canvas.drawBitmap(bitmap, matrix, paint)
+ }
+ } finally {
+ if (canvas != null) {
+ holder.unlockCanvasAndPost(canvas)
+ }
+ }
+ }
+ }
+
+ private data class WallpaperImageResult(val bitmap: android.graphics.Bitmap)
+
+ companion object {
+ private const val TAG = "ImmichWallpaperService"
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt
new file mode 100644
index 0000000000..478dec22ae
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/ImmichWallpaperSettingsActivity.kt
@@ -0,0 +1,31 @@
+package app.alextran.immich.wallpaper
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.widget.Button
+import app.alextran.immich.MainActivity
+import app.alextran.immich.R
+
+class ImmichWallpaperSettingsActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Directly open Immich settings without showing intermediate screen
+ openImmichSettings()
+ }
+
+ private fun openImmichSettings() {
+ val intent = Intent(Intent.ACTION_VIEW, SETTINGS_URI, this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ startActivity(intent)
+ finish()
+ }
+
+ companion object {
+ private val SETTINGS_URI: Uri = Uri.parse("immich://live-wallpaper/settings")
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt
new file mode 100644
index 0000000000..b2e14889d8
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt
@@ -0,0 +1,267 @@
+// Autogenerated from Pigeon (v26.0.0), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
+
+package app.alextran.immich.wallpaper
+
+import android.util.Log
+import io.flutter.plugin.common.BasicMessageChannel
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MessageCodec
+import io.flutter.plugin.common.StandardMethodCodec
+import io.flutter.plugin.common.StandardMessageCodec
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+private object WallpaperApiPigeonUtils {
+
+ fun wrapResult(result: Any?): List {
+ return listOf(result)
+ }
+
+ fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
+ }
+ fun 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).containsKey(it.key) &&
+ deepEquals(it.value, b[it.key])
+ }
+ }
+ return a == b
+ }
+
+}
+
+/**
+ * Error class for passing custom error details to Flutter via a thrown PlatformException.
+ * @property code The error code.
+ * @property message The error message.
+ * @property details The error details. Must be a datatype supported by the api codec.
+ */
+class FlutterError (
+ val code: String,
+ override val message: String? = null,
+ val details: Any? = null
+) : Throwable()
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class WallpaperPreferencesMessage (
+ val enabled: Boolean,
+ val personIds: List,
+ val rotationMinutes: Long,
+ val rotationMode: String,
+ val allowCellularData: Boolean
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): WallpaperPreferencesMessage {
+ val enabled = pigeonVar_list[0] as Boolean
+ val personIds = pigeonVar_list[1] as List
+ val rotationMinutes = pigeonVar_list[2] as Long
+ val rotationMode = pigeonVar_list[3] as String
+ val allowCellularData = pigeonVar_list[4] as Boolean
+ return WallpaperPreferencesMessage(enabled, personIds, rotationMinutes, rotationMode, allowCellularData)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ enabled,
+ personIds,
+ rotationMinutes,
+ rotationMode,
+ allowCellularData,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is WallpaperPreferencesMessage) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return WallpaperApiPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class WallpaperStatusMessage (
+ val isSupported: Boolean,
+ val isActive: Boolean,
+ val lastError: String? = null
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): WallpaperStatusMessage {
+ val isSupported = pigeonVar_list[0] as Boolean
+ val isActive = pigeonVar_list[1] as Boolean
+ val lastError = pigeonVar_list[2] as String?
+ return WallpaperStatusMessage(isSupported, isActive, lastError)
+ }
+ }
+ fun toList(): List {
+ return listOf(
+ isSupported,
+ isActive,
+ lastError,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is WallpaperStatusMessage) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return WallpaperApiPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+private open class WallpaperApiPigeonCodec : StandardMessageCodec() {
+ override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
+ return when (type) {
+ 129.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ WallpaperPreferencesMessage.fromList(it)
+ }
+ }
+ 130.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ WallpaperStatusMessage.fromList(it)
+ }
+ }
+ else -> super.readValueOfType(type, buffer)
+ }
+ }
+ override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
+ when (value) {
+ is WallpaperPreferencesMessage -> {
+ stream.write(129)
+ writeValue(stream, value.toList())
+ }
+ is WallpaperStatusMessage -> {
+ stream.write(130)
+ writeValue(stream, value.toList())
+ }
+ else -> super.writeValue(stream, value)
+ }
+ }
+}
+
+/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
+interface WallpaperHostApi {
+ fun getStatus(): WallpaperStatusMessage
+ fun setPreferences(preferences: WallpaperPreferencesMessage)
+ fun requestRefresh()
+ fun openSystemWallpaperPicker(): Boolean
+
+ companion object {
+ /** The codec used by WallpaperHostApi. */
+ val codec: MessageCodec by lazy {
+ WallpaperApiPigeonCodec()
+ }
+ /** Sets up an instance of `WallpaperHostApi` to handle messages through the `binaryMessenger`. */
+ @JvmOverloads
+ fun setUp(binaryMessenger: BinaryMessenger, api: WallpaperHostApi?, messageChannelSuffix: String = "") {
+ val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.getStatus())
+ } catch (exception: Throwable) {
+ WallpaperApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val preferencesArg = args[0] as WallpaperPreferencesMessage
+ val wrapped: List = try {
+ api.setPreferences(preferencesArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ WallpaperApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.requestRefresh()
+ listOf(null)
+ } catch (exception: Throwable) {
+ WallpaperApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.openSystemWallpaperPicker())
+ } catch (exception: Throwable) {
+ WallpaperApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt
new file mode 100644
index 0000000000..0aed0c0971
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperHostApiImpl.kt
@@ -0,0 +1,52 @@
+package app.alextran.immich.wallpaper
+
+import android.app.WallpaperManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+
+class WallpaperHostApiImpl(private val context: Context) : WallpaperHostApi {
+ private val store = WallpaperPreferencesStore(context)
+
+ override fun getStatus(): WallpaperStatusMessage {
+ val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_LIVE_WALLPAPER)
+ val manager = WallpaperManager.getInstance(context)
+ val component = ComponentName(context, ImmichWallpaperService::class.java)
+ val isActive = runCatching { manager.wallpaperInfo?.component == component }.getOrDefault(false)
+
+ return WallpaperStatusMessage(
+ isSupported = supported,
+ isActive = isActive,
+ lastError = store.getLastError()
+ )
+ }
+
+ override fun setPreferences(preferences: WallpaperPreferencesMessage) {
+ store.save(preferences)
+ if (!preferences.enabled) {
+ store.setLastError(null)
+ }
+ }
+
+ override fun requestRefresh() {
+ store.bumpRefreshToken()
+ }
+
+ override fun openSystemWallpaperPicker(): Boolean {
+ val component = ComponentName(context, ImmichWallpaperService::class.java)
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER).apply {
+ putExtra(WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT, component)
+ }
+ } else {
+ Intent(WallpaperManager.ACTION_LIVE_WALLPAPER_CHOOSER)
+ }.apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ return runCatching { context.startActivity(intent) }.isSuccess
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt
new file mode 100644
index 0000000000..3f77da5da7
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperPreferencesStore.kt
@@ -0,0 +1,117 @@
+package app.alextran.immich.wallpaper
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import org.json.JSONArray
+import org.json.JSONException
+
+import app.alextran.immich.wallpaper.WallpaperPreferencesMessage
+
+/** Convenience wrapper around [WallpaperPreferencesMessage] persistence. */
+
+private const val PREF_NAME = "immich_live_wallpaper"
+internal const val KEY_ENABLED = "enabled"
+internal const val KEY_PERSON_IDS = "person_ids"
+internal const val KEY_ROTATION_MINUTES = "rotation_minutes"
+internal const val KEY_ROTATION_MODE = "rotation_mode"
+internal const val KEY_ALLOW_CELLULAR = "allow_cellular"
+internal const val KEY_LAST_ERROR = "last_error"
+internal const val KEY_REFRESH_TOKEN = "refresh_token"
+internal const val KEY_LAST_ASSET_ID = "last_asset_id"
+internal const val KEY_LAST_REFRESH_AT = "last_refresh_at"
+
+class WallpaperPreferencesStore(private val context: Context) {
+ private val prefs: SharedPreferences by lazy {
+ context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun save(preferences: WallpaperPreferencesMessage) {
+ prefs.edit {
+ putBoolean(KEY_ENABLED, preferences.enabled)
+ putString(KEY_PERSON_IDS, preferences.personIds.toJson())
+ putLong(KEY_ROTATION_MINUTES, preferences.rotationMinutes)
+ putString(KEY_ROTATION_MODE, preferences.rotationMode)
+ putBoolean(KEY_ALLOW_CELLULAR, preferences.allowCellularData)
+ }
+ bumpRefreshToken()
+ }
+
+ fun getPreferences(): WallpaperPreferencesMessage? {
+ if (!prefs.contains(KEY_ENABLED)) {
+ return null
+ }
+
+ val enabled = prefs.getBoolean(KEY_ENABLED, false)
+ val personIds = (prefs.getString(KEY_PERSON_IDS, null) ?: "[]").toStringList()
+ val rotationMinutes = prefs.getLong(KEY_ROTATION_MINUTES, 15)
+ val rotationMode = prefs.getString(KEY_ROTATION_MODE, "minutes") ?: "minutes"
+ val allowCellular = prefs.getBoolean(KEY_ALLOW_CELLULAR, true)
+
+ return WallpaperPreferencesMessage(
+ enabled = enabled,
+ personIds = personIds,
+ rotationMinutes = rotationMinutes,
+ rotationMode = rotationMode,
+ allowCellularData = allowCellular
+ )
+ }
+
+ fun getLastError(): String? = prefs.getString(KEY_LAST_ERROR, null)
+
+ fun setLastError(message: String?) {
+ prefs.edit {
+ if (message.isNullOrBlank()) {
+ remove(KEY_LAST_ERROR)
+ } else {
+ putString(KEY_LAST_ERROR, message)
+ }
+ }
+ }
+
+ fun bumpRefreshToken() {
+ prefs.edit {
+ putLong(KEY_REFRESH_TOKEN, System.currentTimeMillis())
+ }
+ }
+
+ fun getRefreshToken(): Long = prefs.getLong(KEY_REFRESH_TOKEN, 0)
+
+ fun setLastAssetId(assetId: String) {
+ prefs.edit {
+ putString(KEY_LAST_ASSET_ID, assetId)
+ putLong(KEY_LAST_REFRESH_AT, System.currentTimeMillis())
+ }
+ }
+
+ fun getLastAssetId(): String? = prefs.getString(KEY_LAST_ASSET_ID, null)
+
+ fun getLastRefreshAt(): Long = prefs.getLong(KEY_LAST_REFRESH_AT, 0)
+
+ fun registerListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun unregisterListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ private fun List.toJson(): String {
+ val array = JSONArray()
+ forEach { array.put(it) }
+ return array.toString()
+ }
+
+ private fun String.toStringList(): List {
+ return try {
+ val array = JSONArray(this)
+ val results = mutableListOf()
+ for (i in 0 until array.length()) {
+ results.add(array.optString(i))
+ }
+ results
+ } catch (_: JSONException) {
+ emptyList()
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt
index 545a1edc59..49a0787aa9 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt
@@ -18,7 +18,9 @@ data class SearchFilters(
var type: AssetType = AssetType.IMAGE,
val size: Int = 1,
var albumIds: List = listOf(),
- var isFavorite: Boolean? = null
+ var isFavorite: Boolean? = null,
+ var personIds: List = listOf(),
+ var withPeople: Boolean? = null
)
data class MemoryResult(
diff --git a/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml b/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml
new file mode 100644
index 0000000000..b7780bdb77
--- /dev/null
+++ b/mobile/android/app/src/main/res/layout/activity_wallpaper_settings.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml
index 5ac495ebb5..1847a05442 100644
--- a/mobile/android/app/src/main/res/values/strings.xml
+++ b/mobile/android/app/src/main/res/values/strings.xml
@@ -1,8 +1,14 @@
+ Immich
Memories
Random
See memories from Immich.
View a random image from your library or a specific album.
+
+ Immich live wallpaper
+ Preview the wallpaper, then jump back into Immich to choose who appears.
+ Open Immich settings
+ Back to wallpaper preview
diff --git a/mobile/android/app/src/main/res/xml/immich_wallpaper.xml b/mobile/android/app/src/main/res/xml/immich_wallpaper.xml
new file mode 100644
index 0000000000..44883b365a
--- /dev/null
+++ b/mobile/android/app/src/main/res/xml/immich_wallpaper.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/mobile/lib/domain/models/live_wallpaper_preferences.model.dart b/mobile/lib/domain/models/live_wallpaper_preferences.model.dart
new file mode 100644
index 0000000000..cd5a0c8c65
--- /dev/null
+++ b/mobile/lib/domain/models/live_wallpaper_preferences.model.dart
@@ -0,0 +1,153 @@
+import 'dart:convert';
+
+const _defaultRotationMinutes = 30;
+
+enum RotationMode {
+ lockUnlock,
+ minutes,
+ hours,
+ daily,
+}
+
+
+
+extension RotationModeExtension on RotationMode {
+ String get label {
+ switch (this) {
+ case RotationMode.lockUnlock:
+ return 'Lock/Unlock';
+ case RotationMode.minutes:
+ return 'Minute';
+ case RotationMode.hours:
+ return 'Hour';
+ case RotationMode.daily:
+ return 'Daily';
+ }
+ }
+
+ Duration get duration {
+ switch (this) {
+ case RotationMode.lockUnlock:
+ return Duration.zero; // Special case for lock/unlock
+ case RotationMode.minutes:
+ return const Duration(minutes: 1);
+ case RotationMode.hours:
+ return const Duration(hours: 1);
+ case RotationMode.daily:
+ return const Duration(days: 1);
+ }
+ }
+}
+
+
+
+class LiveWallpaperPreferences {
+ const LiveWallpaperPreferences({
+ required this.enabled,
+ required this.personIds,
+ required this.rotationInterval,
+ required this.rotationMode,
+ required this.allowCellularData,
+ this.lastAssetId,
+ this.lastUpdated,
+ });
+
+ const LiveWallpaperPreferences.defaults()
+ : enabled = false,
+ personIds = const [],
+ rotationInterval = const Duration(minutes: _defaultRotationMinutes),
+ rotationMode = RotationMode.minutes,
+ allowCellularData = false,
+ lastAssetId = null,
+ lastUpdated = null;
+
+ final bool enabled;
+ final List personIds;
+ final Duration rotationInterval;
+ final RotationMode rotationMode;
+ final bool allowCellularData;
+ final String? lastAssetId;
+ final DateTime? lastUpdated;
+
+ bool get hasSelection => personIds.isNotEmpty;
+
+ LiveWallpaperPreferences copyWith({
+ bool? enabled,
+ List? personIds,
+ Duration? rotationInterval,
+ RotationMode? rotationMode,
+ bool? allowCellularData,
+ String? lastAssetId,
+ DateTime? lastUpdated,
+ }) {
+ return LiveWallpaperPreferences(
+ enabled: enabled ?? this.enabled,
+ personIds: personIds ?? this.personIds,
+ rotationInterval: rotationInterval ?? this.rotationInterval,
+ rotationMode: rotationMode ?? this.rotationMode,
+ allowCellularData: allowCellularData ?? this.allowCellularData,
+ lastAssetId: lastAssetId ?? this.lastAssetId,
+ lastUpdated: lastUpdated ?? this.lastUpdated,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'enabled': enabled,
+ 'personIds': personIds,
+ 'rotationMinutes': rotationInterval.inMinutes,
+ 'rotationMode': rotationMode.name,
+ 'allowCellularData': allowCellularData,
+ 'lastAssetId': lastAssetId,
+ 'lastUpdated': lastUpdated?.toIso8601String(),
+ };
+ }
+
+ String encode() => jsonEncode(toJson());
+
+ static LiveWallpaperPreferences decode(String? source) {
+ if (source == null || source.isEmpty) {
+ return const LiveWallpaperPreferences.defaults();
+ }
+
+ try {
+ final map = jsonDecode(source) as Map;
+ return LiveWallpaperPreferences(
+ enabled: map['enabled'] as bool? ?? false,
+ personIds: (map['personIds'] as List? ?? const []).cast(),
+ rotationInterval: Duration(minutes: _parseInt(map['rotationMinutes'], _defaultRotationMinutes)),
+ rotationMode: _parseRotationMode(map['rotationMode'] as String?),
+ allowCellularData: map['allowCellularData'] as bool? ?? false,
+ lastAssetId: map['lastAssetId'] as String?,
+ lastUpdated: _parseDate(map['lastUpdated'] as String?),
+ );
+ } catch (_) {
+ return const LiveWallpaperPreferences.defaults();
+ }
+ }
+
+ static int _parseInt(Object? value, int fallback) {
+ if (value is int) return value;
+ if (value is String) {
+ final parsed = int.tryParse(value);
+ if (parsed != null) return parsed;
+ }
+ return fallback;
+ }
+
+ static DateTime? _parseDate(String? value) {
+ if (value == null || value.isEmpty) return null;
+ return DateTime.tryParse(value);
+ }
+
+ static RotationMode _parseRotationMode(String? value) {
+ if (value == null) return RotationMode.minutes;
+ try {
+ return RotationMode.values.firstWhere((e) => e.name == value);
+ } catch (_) {
+ return RotationMode.minutes;
+ }
+ }
+
+
+}
diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart
index efccc9bccd..efe117936d 100644
--- a/mobile/lib/domain/models/store.model.dart
+++ b/mobile/lib/domain/models/store.model.dart
@@ -79,7 +79,10 @@ enum StoreKey {
useWifiForUploadPhotos._(1005),
needBetaMigration._(1006),
// TODO: Remove this after patching open-api
- shouldResetSync._(1007);
+ shouldResetSync._(1007),
+
+ // Live wallpaper configuration
+ wallpaperPreferences._(1008);
const StoreKey._(this.id);
final int id;
diff --git a/mobile/lib/domain/services/live_wallpaper_asset.service.dart b/mobile/lib/domain/services/live_wallpaper_asset.service.dart
new file mode 100644
index 0000000000..f37bc5def4
--- /dev/null
+++ b/mobile/lib/domain/services/live_wallpaper_asset.service.dart
@@ -0,0 +1,34 @@
+import 'dart:math';
+
+import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/repositories/asset_api.repository.dart';
+
+class LiveWallpaperAssetService {
+ LiveWallpaperAssetService(this._assetApiRepository);
+
+ final AssetApiRepository _assetApiRepository;
+ final Random _random = Random();
+
+ Future pickRandomAsset(List personIds, {String? excludeRemoteId}) async {
+ if (personIds.isEmpty) {
+ return null;
+ }
+
+ final assets = await _assetApiRepository.search(personIds: personIds);
+ if (assets.isEmpty) {
+ return null;
+ }
+
+ final filtered = assets.where((asset) {
+ final matchesType = asset.type == AssetType.image;
+ final matchesRemoteId = excludeRemoteId == null || asset.remoteId != excludeRemoteId;
+ return matchesType && matchesRemoteId;
+ }).toList();
+
+ if (filtered.isEmpty) {
+ return null;
+ }
+
+ return filtered[_random.nextInt(filtered.length)];
+ }
+}
diff --git a/mobile/lib/domain/services/live_wallpaper_preferences.service.dart b/mobile/lib/domain/services/live_wallpaper_preferences.service.dart
new file mode 100644
index 0000000000..b92087862d
--- /dev/null
+++ b/mobile/lib/domain/services/live_wallpaper_preferences.service.dart
@@ -0,0 +1,24 @@
+import 'dart:async';
+
+import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
+import 'package:immich_mobile/domain/models/store.model.dart';
+import 'package:immich_mobile/domain/services/store.service.dart';
+
+class LiveWallpaperPreferencesService {
+ LiveWallpaperPreferencesService(this._storeService);
+
+ final StoreService _storeService;
+
+ LiveWallpaperPreferences load() {
+ final raw = _storeService.tryGet(StoreKey.wallpaperPreferences);
+ return LiveWallpaperPreferences.decode(raw);
+ }
+
+ Future save(LiveWallpaperPreferences preferences) {
+ return _storeService.put(StoreKey.wallpaperPreferences, preferences.encode());
+ }
+
+ Stream watch() {
+ return _storeService.watch(StoreKey.wallpaperPreferences).map(LiveWallpaperPreferences.decode);
+ }
+}
diff --git a/mobile/lib/pages/common/live_wallpaper_setup.page.dart b/mobile/lib/pages/common/live_wallpaper_setup.page.dart
new file mode 100644
index 0000000000..1003eece0a
--- /dev/null
+++ b/mobile/lib/pages/common/live_wallpaper_setup.page.dart
@@ -0,0 +1,254 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/person.model.dart';
+import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
+import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
+import 'package:immich_mobile/providers/live_wallpaper_platform.provider.dart';
+import 'package:immich_mobile/providers/live_wallpaper_preferences.provider.dart';
+import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+
+@RoutePage()
+class LiveWallpaperSetupPage extends HookConsumerWidget {
+ const LiveWallpaperSetupPage({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final preferences = ref.watch(liveWallpaperPreferencesProvider);
+ final notifier = ref.watch(liveWallpaperPreferencesProvider.notifier);
+ final openPicker = ref.watch(openWallpaperPickerProvider);
+
+ final searchQuery = useState('');
+ final selected = useState>(preferences.personIds.toSet());
+ final rotationMode = useState(preferences.rotationMode);
+
+ useEffect(() {
+ selected.value = preferences.personIds.toSet();
+ rotationMode.value = preferences.rotationMode;
+ return null;
+ }, [preferences.personIds, preferences.rotationMode]);
+
+ final peopleAsync = ref.watch(driftGetAllPeopleProvider);
+
+ void togglePerson(String personId) {
+ final next = Set.from(selected.value);
+ if (next.contains(personId)) {
+ next.remove(personId);
+ } else {
+ next.add(personId);
+ }
+ selected.value = next;
+ notifier.setPersonIds(next.toList());
+ }
+
+ void onRotationChanged(RotationMode mode) {
+ rotationMode.value = mode;
+ notifier.setRotationMode(mode);
+ notifier.setRotationInterval(mode.duration);
+ }
+
+ void onAllowCellularChanged(bool value) {
+ notifier.setAllowCellular(value);
+ }
+
+ Future onSetWallpaper() async {
+ if (selected.value.isEmpty) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('live_wallpaper_setup_select_people_first'.tr())));
+ return;
+ }
+
+ final wasEnabled = preferences.enabled;
+ await notifier.toggleEnabled(true);
+
+ try {
+ final opened = await openPicker();
+ if (!context.mounted) return;
+
+ final key = opened ? 'live_wallpaper_setting_open_picker_success' : 'live_wallpaper_setting_open_picker_failed';
+ if (!opened && !wasEnabled) {
+ await notifier.toggleEnabled(false);
+ }
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(key.tr())));
+ } catch (error) {
+ if (!wasEnabled) {
+ await notifier.toggleEnabled(false);
+ }
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('live_wallpaper_status_error'.tr(namedArgs: {'error': error.toString()}))),
+ );
+ }
+ }
+ }
+
+
+
+ return Scaffold(
+ appBar: AppBar(title: Text('live_wallpaper_setup_title'.tr())),
+ body: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ TextField(
+ decoration: InputDecoration(
+ prefixIcon: const Icon(Icons.search),
+ hintText: 'live_wallpaper_setup_search_hint'.tr(),
+ border: const OutlineInputBorder(),
+ ),
+ onChanged: (value) => searchQuery.value = value,
+ ),
+ const SizedBox(height: 12),
+ Expanded(
+ child: peopleAsync.when(
+ data: (people) => _PeopleGrid(
+ people: _filterPeople(people, searchQuery.value),
+ selected: selected.value,
+ onPersonTap: togglePerson,
+ ),
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (error, stack) => Center(child: Text('live_wallpaper_setup_error_loading_people'.tr())),
+ ),
+ ),
+ const SizedBox(height: 16),
+ _RotationControl(mode: rotationMode.value, onChanged: onRotationChanged),
+ SwitchListTile.adaptive(
+ value: preferences.allowCellularData,
+ onChanged: onAllowCellularChanged,
+ title: Text('live_wallpaper_setup_allow_cellular'.tr()),
+ subtitle: Text('live_wallpaper_setup_allow_cellular_subtitle'.tr()),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ icon: const Icon(Icons.wallpaper),
+ label: Text('live_wallpaper_setup_enable_button'.tr()),
+ onPressed: onSetWallpaper,
+ ),
+ ),
+
+ ],
+ ),
+ ),
+ );
+ }
+
+ static List _filterPeople(List people, String search) {
+ if (search.isEmpty) {
+ return people;
+ }
+ final lower = search.toLowerCase();
+ return people.where((person) => person.name.toLowerCase().contains(lower)).toList();
+ }
+}
+
+class _PeopleGrid extends StatelessWidget {
+ const _PeopleGrid({required this.people, required this.selected, required this.onPersonTap});
+
+ final List people;
+ final Set selected;
+ final ValueChanged onPersonTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final headers = ApiService.getRequestHeaders();
+ if (people.isEmpty) {
+ return Center(child: Text('live_wallpaper_setup_no_results'.tr()));
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final estimated = (constraints.maxWidth / 110).floor();
+ final count = estimated.clamp(2, 4);
+
+ return GridView.builder(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: count,
+ childAspectRatio: 0.72,
+ crossAxisSpacing: 10,
+ mainAxisSpacing: 16,
+ ),
+ itemCount: people.length,
+ itemBuilder: (context, index) {
+ final person = people[index];
+ final isSelected = selected.contains(person.id);
+ final imageUrl = getFaceThumbnailUrl(person.id);
+
+ return GestureDetector(
+ onTap: () => onPersonTap(person.id),
+ child: Column(
+ children: [
+ Stack(
+ alignment: Alignment.topRight,
+ children: [
+ CircleAvatar(radius: 42, backgroundImage: NetworkImage(imageUrl, headers: headers)),
+ if (isSelected)
+ Container(
+ margin: const EdgeInsets.all(4),
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary,
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(Icons.check, size: 16, color: Colors.white),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ person.name.isEmpty ? 'live_wallpaper_setup_unknown_person'.tr() : person.name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ );
+ }
+}
+
+class _RotationControl extends StatelessWidget {
+ const _RotationControl({required this.mode, required this.onChanged});
+
+ final RotationMode mode;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('live_wallpaper_setup_rotation_label'.tr()),
+ const SizedBox(height: 8),
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: RotationMode.values.map((rotationMode) {
+ final isSelected = mode == rotationMode;
+ return Padding(
+ padding: const EdgeInsets.only(right: 8.0),
+ child: FilterChip(
+ label: Text(rotationMode.label),
+ selected: isSelected,
+ onSelected: (_) => onChanged(rotationMode),
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+
diff --git a/mobile/lib/platform/wallpaper_api.g.dart b/mobile/lib/platform/wallpaper_api.g.dart
new file mode 100644
index 0000000000..1906e629e6
--- /dev/null
+++ b/mobile/lib/platform/wallpaper_api.g.dart
@@ -0,0 +1,290 @@
+// Autogenerated from Pigeon (v26.0.0), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+PlatformException _createConnectionError(String channelName) {
+ return PlatformException(
+ code: 'channel-error',
+ message: 'Unable to establish connection on channel: "$channelName".',
+ );
+}
+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