mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge b4d09d0219 into 9d639607c7
This commit is contained in:
commit
34cf793a1e
27 changed files with 1895 additions and 3 deletions
28
i18n/en.json
28
i18n/en.json
|
|
@ -1234,6 +1234,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",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER_HINTS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.live_wallpaper"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Foreground service permission -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
|
@ -38,6 +44,23 @@
|
|||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|shortService" />
|
||||
|
||||
<service
|
||||
android:name=".wallpaper.ImmichWallpaperService"
|
||||
android:permission="android.permission.BIND_WALLPAPER"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.wallpaper.WallpaperService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.wallpaper"
|
||||
android:resource="@xml/immich_wallpaper" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".wallpaper.ImmichWallpaperSettingsActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Material3.DayNight.NoActionBar" />
|
||||
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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<ConnectivityManager>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String>,
|
||||
val rotationMinutes: Long,
|
||||
val rotationMode: String,
|
||||
val allowCellularData: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): WallpaperPreferencesMessage {
|
||||
val enabled = pigeonVar_list[0] as Boolean
|
||||
val personIds = pigeonVar_list[1] as List<String>
|
||||
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<Any?> {
|
||||
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<Any?>): 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<Any?> {
|
||||
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<Any?>)?.let {
|
||||
WallpaperPreferencesMessage.fromList(it)
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.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<Any?> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.getStatus())
|
||||
} catch (exception: Throwable) {
|
||||
WallpaperApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val preferencesArg = args[0] as WallpaperPreferencesMessage
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setPreferences(preferencesArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
WallpaperApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.requestRefresh()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
WallpaperApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.openSystemWallpaperPicker())
|
||||
} catch (exception: Throwable) {
|
||||
WallpaperApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>.toJson(): String {
|
||||
val array = JSONArray()
|
||||
forEach { array.put(it) }
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun String.toStringList(): List<String> {
|
||||
return try {
|
||||
val array = JSONArray(this)
|
||||
val results = mutableListOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
results.add(array.optString(i))
|
||||
}
|
||||
results
|
||||
} catch (_: JSONException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ data class SearchFilters(
|
|||
var type: AssetType = AssetType.IMAGE,
|
||||
val size: Int = 1,
|
||||
var albumIds: List<String> = listOf(),
|
||||
var isFavorite: Boolean? = null
|
||||
var isFavorite: Boolean? = null,
|
||||
var personIds: List<String> = listOf(),
|
||||
var withPeople: Boolean? = null
|
||||
)
|
||||
|
||||
data class MemoryResult(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/wallpaper_settings_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/wallpaper_settings_title"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleLarge"
|
||||
android:textAlignment="center"
|
||||
android:paddingBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/wallpaper_settings_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/wallpaper_settings_description"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textAlignment="center"
|
||||
android:paddingBottom="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/wallpaper_settings_open_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/wallpaper_settings_open_button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/wallpaper_settings_back_button"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/wallpaper_settings_back_button" />
|
||||
|
||||
</LinearLayout>
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Immich</string>
|
||||
<string name="memory_widget_title">Memories</string>
|
||||
<string name="random_widget_title">Random</string>
|
||||
|
||||
<string name="memory_widget_description">See memories from Immich.</string>
|
||||
<string name="random_widget_description">View a random image from your library or a specific album.</string>
|
||||
|
||||
<string name="wallpaper_settings_title">Immich live wallpaper</string>
|
||||
<string name="wallpaper_settings_description">Preview the wallpaper, then jump back into Immich to choose who appears.</string>
|
||||
<string name="wallpaper_settings_open_button">Open Immich settings</string>
|
||||
<string name="wallpaper_settings_back_button">Back to wallpaper preview</string>
|
||||
</resources>
|
||||
|
|
|
|||
5
mobile/android/app/src/main/res/xml/immich_wallpaper.xml
Normal file
5
mobile/android/app/src/main/res/xml/immich_wallpaper.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/app_name"
|
||||
android:thumbnail="@mipmap/ic_launcher"
|
||||
android:settingsActivity="app.alextran.immich.wallpaper.ImmichWallpaperSettingsActivity" />
|
||||
142
mobile/lib/domain/models/live_wallpaper_preferences.model.dart
Normal file
142
mobile/lib/domain/models/live_wallpaper_preferences.model.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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<String> 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<String>? 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'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<String, dynamic>;
|
||||
return LiveWallpaperPreferences(
|
||||
enabled: map['enabled'] as bool? ?? false,
|
||||
personIds: (map['personIds'] as List? ?? const []).cast<String>(),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,10 @@ enum StoreKey<T> {
|
|||
useWifiForUploadPhotos<bool>._(1005),
|
||||
needBetaMigration<bool>._(1006),
|
||||
// TODO: Remove this after patching open-api
|
||||
shouldResetSync<bool>._(1007);
|
||||
shouldResetSync<bool>._(1007),
|
||||
|
||||
// Live wallpaper configuration
|
||||
wallpaperPreferences<String>._(1008);
|
||||
|
||||
const StoreKey._(this.id);
|
||||
final int id;
|
||||
|
|
|
|||
34
mobile/lib/domain/services/live_wallpaper_asset.service.dart
Normal file
34
mobile/lib/domain/services/live_wallpaper_asset.service.dart
Normal file
|
|
@ -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<Asset?> pickRandomAsset(List<String> 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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> save(LiveWallpaperPreferences preferences) {
|
||||
return _storeService.put(StoreKey.wallpaperPreferences, preferences.encode());
|
||||
}
|
||||
|
||||
Stream<LiveWallpaperPreferences> watch() {
|
||||
return _storeService.watch(StoreKey.wallpaperPreferences).map(LiveWallpaperPreferences.decode);
|
||||
}
|
||||
}
|
||||
249
mobile/lib/pages/common/live_wallpaper_setup.page.dart
Normal file
249
mobile/lib/pages/common/live_wallpaper_setup.page.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
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<Set<String>>(preferences.personIds.toSet());
|
||||
final rotationMode = useState<RotationMode>(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<String>.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<void> 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<DriftPerson> _filterPeople(List<DriftPerson> 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<DriftPerson> people;
|
||||
final Set<String> selected;
|
||||
final ValueChanged<String> 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<RotationMode> 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
290
mobile/lib/platform/wallpaper_api.g.dart
generated
Normal file
290
mobile/lib/platform/wallpaper_api.g.dart
generated
Normal file
|
|
@ -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<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
class WallpaperPreferencesMessage {
|
||||
WallpaperPreferencesMessage({
|
||||
required this.enabled,
|
||||
required this.personIds,
|
||||
required this.rotationMinutes,
|
||||
required this.rotationMode,
|
||||
required this.allowCellularData,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
List<String> personIds;
|
||||
|
||||
int rotationMinutes;
|
||||
|
||||
String rotationMode;
|
||||
|
||||
bool allowCellularData;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
enabled,
|
||||
personIds,
|
||||
rotationMinutes,
|
||||
rotationMode,
|
||||
allowCellularData,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static WallpaperPreferencesMessage decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return WallpaperPreferencesMessage(
|
||||
enabled: result[0]! as bool,
|
||||
personIds: (result[1] as List<Object?>?)!.cast<String>(),
|
||||
rotationMinutes: result[2]! as int,
|
||||
rotationMode: result[3]! as String,
|
||||
allowCellularData: result[4]! as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! WallpaperPreferencesMessage || 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 WallpaperStatusMessage {
|
||||
WallpaperStatusMessage({
|
||||
required this.isSupported,
|
||||
required this.isActive,
|
||||
this.lastError,
|
||||
});
|
||||
|
||||
bool isSupported;
|
||||
|
||||
bool isActive;
|
||||
|
||||
String? lastError;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
isSupported,
|
||||
isActive,
|
||||
lastError,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static WallpaperStatusMessage decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return WallpaperStatusMessage(
|
||||
isSupported: result[0]! as bool,
|
||||
isActive: result[1]! as bool,
|
||||
lastError: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! WallpaperStatusMessage || 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
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is WallpaperPreferencesMessage) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is WallpaperStatusMessage) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
return WallpaperPreferencesMessage.decode(readValue(buffer)!);
|
||||
case 130:
|
||||
return WallpaperStatusMessage.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WallpaperHostApi {
|
||||
/// Constructor for [WallpaperHostApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
WallpaperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<WallpaperStatusMessage> getStatus() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
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 if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as WallpaperStatusMessage?)!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPreferences(WallpaperPreferencesMessage preferences) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.setPreferences$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[preferences]);
|
||||
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> requestRefresh() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.requestRefresh$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
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<bool> openSystemWallpaperPicker() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.WallpaperHostApi.openSystemWallpaperPicker$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
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 if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as bool?)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
mobile/lib/providers/live_wallpaper_platform.provider.dart
Normal file
21
mobile/lib/providers/live_wallpaper_platform.provider.dart
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/live_wallpaper_preferences.provider.dart';
|
||||
import 'package:immich_mobile/services/live_wallpaper_platform.service.dart';
|
||||
import 'package:immich_mobile/platform/wallpaper_api.g.dart';
|
||||
|
||||
final liveWallpaperStatusProvider = FutureProvider<WallpaperStatusMessage>((ref) async {
|
||||
final preferences = ref.watch(liveWallpaperPreferencesProvider);
|
||||
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
|
||||
await platformService.syncPreferences(preferences);
|
||||
return platformService.getStatus();
|
||||
});
|
||||
|
||||
final openWallpaperPickerProvider = Provider<Future<bool> Function()>((ref) {
|
||||
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
|
||||
return () => platformService.openSystemPicker();
|
||||
});
|
||||
|
||||
final requestWallpaperRefreshProvider = Provider<Future<void> Function()>((ref) {
|
||||
final platformService = ref.watch(liveWallpaperPlatformServiceProvider);
|
||||
return () => platformService.requestRefresh();
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
|
||||
import 'package:immich_mobile/domain/services/live_wallpaper_preferences.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/live_wallpaper_asset.service.dart';
|
||||
|
||||
final liveWallpaperPreferencesServiceProvider = Provider<LiveWallpaperPreferencesService>((ref) {
|
||||
return LiveWallpaperPreferencesService(ref.watch(storeServiceProvider));
|
||||
});
|
||||
|
||||
final liveWallpaperAssetServiceProvider = Provider<LiveWallpaperAssetService>((ref) {
|
||||
return LiveWallpaperAssetService(ref.watch(assetApiRepositoryProvider));
|
||||
});
|
||||
|
||||
final liveWallpaperPreferencesProvider =
|
||||
StateNotifierProvider<LiveWallpaperPreferencesNotifier, LiveWallpaperPreferences>((ref) {
|
||||
final service = ref.watch(liveWallpaperPreferencesServiceProvider);
|
||||
return LiveWallpaperPreferencesNotifier(service);
|
||||
});
|
||||
|
||||
class LiveWallpaperPreferencesNotifier extends StateNotifier<LiveWallpaperPreferences> {
|
||||
LiveWallpaperPreferencesNotifier(this._service) : super(_service.load()) {
|
||||
_subscription = _service.watch().listen((event) {
|
||||
if (mounted) {
|
||||
state = event;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final LiveWallpaperPreferencesService _service;
|
||||
StreamSubscription<LiveWallpaperPreferences>? _subscription;
|
||||
|
||||
Future<void> toggleEnabled(bool enabled) async {
|
||||
await _set(state.copyWith(enabled: enabled));
|
||||
}
|
||||
|
||||
Future<void> setPersonIds(List<String> personIds) async {
|
||||
await _set(state.copyWith(personIds: List.unmodifiable(personIds)));
|
||||
}
|
||||
|
||||
Future<void> setRotationInterval(Duration interval) async {
|
||||
await _set(state.copyWith(rotationInterval: interval));
|
||||
}
|
||||
|
||||
Future<void> setRotationMode(RotationMode mode) async {
|
||||
await _set(state.copyWith(rotationMode: mode));
|
||||
}
|
||||
|
||||
Future<void> setAllowCellular(bool allow) async {
|
||||
await _set(state.copyWith(allowCellularData: allow));
|
||||
}
|
||||
|
||||
Future<void> setLastAsset(String? assetId, {DateTime? updatedAt}) async {
|
||||
await _set(state.copyWith(lastAssetId: assetId, lastUpdated: updatedAt));
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
await _set(const LiveWallpaperPreferences.defaults());
|
||||
}
|
||||
|
||||
Future<void> _set(LiveWallpaperPreferences preferences) async {
|
||||
state = preferences;
|
||||
await _service.save(preferences);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import 'package:immich_mobile/pages/common/settings.page.dart';
|
|||
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/pages/common/tab_controller.page.dart';
|
||||
import 'package:immich_mobile/pages/common/tab_shell.page.dart';
|
||||
import 'package:immich_mobile/pages/common/live_wallpaper_setup.page.dart';
|
||||
import 'package:immich_mobile/pages/editing/crop.page.dart';
|
||||
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||
import 'package:immich_mobile/pages/editing/filter.page.dart';
|
||||
|
|
@ -233,6 +234,7 @@ class AppRouter extends RootStackRouter {
|
|||
),
|
||||
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: LiveWallpaperSetupRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
||||
CustomRoute(
|
||||
|
|
|
|||
|
|
@ -1822,6 +1822,22 @@ class LibraryRoute extends PageRouteInfo<void> {
|
|||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LiveWallpaperSetupPage]
|
||||
class LiveWallpaperSetupRoute extends PageRouteInfo<void> {
|
||||
const LiveWallpaperSetupRoute({List<PageRouteInfo>? children})
|
||||
: super(LiveWallpaperSetupRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'LiveWallpaperSetupRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const LiveWallpaperSetupPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LocalAlbumsPage]
|
||||
class LocalAlbumsRoute extends PageRouteInfo<void> {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class DeepLinkService {
|
|||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||
"live-wallpaper" => _buildLiveWallpaperDeepLink(link.uri.pathSegments),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
|
@ -190,4 +191,11 @@ class DeepLinkService {
|
|||
return AlbumViewerRoute(albumId: album.id);
|
||||
}
|
||||
}
|
||||
|
||||
PageRouteInfo<dynamic>? _buildLiveWallpaperDeepLink(List<String> pathSegments) {
|
||||
if (pathSegments.isEmpty || pathSegments.first == 'settings') {
|
||||
return const LiveWallpaperSetupRoute();
|
||||
}
|
||||
return const LiveWallpaperSetupRoute();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
mobile/lib/services/live_wallpaper_platform.service.dart
Normal file
34
mobile/lib/services/live_wallpaper_platform.service.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/live_wallpaper_preferences.model.dart';
|
||||
import 'package:immich_mobile/platform/wallpaper_api.g.dart';
|
||||
|
||||
class LiveWallpaperPlatformService {
|
||||
LiveWallpaperPlatformService(this._hostApi);
|
||||
|
||||
final WallpaperHostApi _hostApi;
|
||||
|
||||
Future<WallpaperStatusMessage> getStatus() {
|
||||
return _hostApi.getStatus();
|
||||
}
|
||||
|
||||
Future<void> syncPreferences(LiveWallpaperPreferences preferences) {
|
||||
final message = WallpaperPreferencesMessage(
|
||||
enabled: preferences.enabled,
|
||||
personIds: preferences.personIds,
|
||||
rotationMinutes: preferences.rotationInterval.inMinutes,
|
||||
rotationMode: preferences.rotationMode.name,
|
||||
allowCellularData: preferences.allowCellularData,
|
||||
);
|
||||
return _hostApi.setPreferences(message);
|
||||
}
|
||||
|
||||
Future<void> requestRefresh() => _hostApi.requestRefresh();
|
||||
|
||||
Future<bool> openSystemPicker() => _hostApi.openSystemWallpaperPicker();
|
||||
}
|
||||
|
||||
final liveWallpaperPlatformServiceProvider = Provider<LiveWallpaperPlatformService>((_) {
|
||||
return LiveWallpaperPlatformService(WallpaperHostApi());
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/live_wallpaper_platform.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
|
||||
|
||||
class LiveWallpaperSetting extends HookConsumerWidget {
|
||||
const LiveWallpaperSetting({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Only show live wallpaper settings on Android
|
||||
if (!Platform.isAndroid) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusAsync = ref.watch(liveWallpaperStatusProvider);
|
||||
|
||||
final statusText = statusAsync.when<String?>(
|
||||
data: (status) {
|
||||
if (!status.isSupported) {
|
||||
return 'live_wallpaper_status_not_supported'.tr();
|
||||
}
|
||||
if (status.lastError != null && status.lastError!.isNotEmpty) {
|
||||
return 'live_wallpaper_status_error'.tr(namedArgs: {'error': status.lastError!});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
loading: () => 'live_wallpaper_status_syncing'.tr(),
|
||||
error: (error, _) => 'live_wallpaper_status_error'.tr(namedArgs: {'error': error.toString()}),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSubTitle(title: 'live_wallpaper_setting_title'.tr()),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text('live_wallpaper_setting_manage'.tr()),
|
||||
subtitle: statusText == null ? null : Text(statusText, style: Theme.of(context).textTheme.bodySmall),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.pushRoute(const LiveWallpaperSetupRoute()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/theme_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/preference_settings/live_wallpaper_setting.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||
|
||||
class PreferenceSetting extends StatelessWidget {
|
||||
|
|
@ -8,7 +9,7 @@ class PreferenceSetting extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const preferenceSettings = [ThemeSetting(), HapticSetting()];
|
||||
const preferenceSettings = [ThemeSetting(), HapticSetting(), LiveWallpaperSetting()];
|
||||
|
||||
return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true);
|
||||
}
|
||||
|
|
|
|||
45
mobile/pigeon/wallpaper_api.dart
Normal file
45
mobile/pigeon/wallpaper_api.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/wallpaper_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/wallpaper/WallpaperApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.wallpaper'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
class WallpaperPreferencesMessage {
|
||||
final bool enabled;
|
||||
final List<String> personIds;
|
||||
final int rotationMinutes;
|
||||
final String rotationMode;
|
||||
final bool allowCellularData;
|
||||
|
||||
const WallpaperPreferencesMessage({
|
||||
required this.enabled,
|
||||
required this.personIds,
|
||||
required this.rotationMinutes,
|
||||
required this.rotationMode,
|
||||
required this.allowCellularData,
|
||||
});
|
||||
}
|
||||
|
||||
class WallpaperStatusMessage {
|
||||
final bool isSupported;
|
||||
final bool isActive;
|
||||
final String? lastError;
|
||||
|
||||
const WallpaperStatusMessage({this.isSupported = true, this.isActive = false, this.lastError});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class WallpaperHostApi {
|
||||
WallpaperStatusMessage getStatus();
|
||||
|
||||
void setPreferences(WallpaperPreferencesMessage preferences);
|
||||
|
||||
void requestRefresh();
|
||||
|
||||
bool openSystemWallpaperPicker();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue