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",
|
"link_to_oauth": "Link to OAuth",
|
||||||
"linked_oauth_account": "Linked OAuth account",
|
"linked_oauth_account": "Linked OAuth account",
|
||||||
"list": "List",
|
"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": "Loading",
|
||||||
"loading_search_results_failed": "Loading search results failed",
|
"loading_search_results_failed": "Loading search results failed",
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<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 -->
|
<!-- Foreground service permission -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
@ -38,6 +44,23 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync|shortService" />
|
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
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="false" />
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import app.alextran.immich.images.ThumbnailsImpl
|
||||||
import app.alextran.immich.sync.NativeSyncApi
|
import app.alextran.immich.sync.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
|
import app.alextran.immich.wallpaper.WallpaperHostApi
|
||||||
|
import app.alextran.immich.wallpaper.WallpaperHostApiImpl
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
|
|
@ -38,6 +40,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx))
|
||||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||||
|
WallpaperHostApi.setUp(messenger, WallpaperHostApiImpl(ctx))
|
||||||
|
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
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,
|
var type: AssetType = AssetType.IMAGE,
|
||||||
val size: Int = 1,
|
val size: Int = 1,
|
||||||
var albumIds: List<String> = listOf(),
|
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(
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<string name="app_name">Immich</string>
|
||||||
<string name="memory_widget_title">Memories</string>
|
<string name="memory_widget_title">Memories</string>
|
||||||
<string name="random_widget_title">Random</string>
|
<string name="random_widget_title">Random</string>
|
||||||
|
|
||||||
<string name="memory_widget_description">See memories from Immich.</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="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>
|
</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),
|
useWifiForUploadPhotos<bool>._(1005),
|
||||||
needBetaMigration<bool>._(1006),
|
needBetaMigration<bool>._(1006),
|
||||||
// TODO: Remove this after patching open-api
|
// TODO: Remove this after patching open-api
|
||||||
shouldResetSync<bool>._(1007);
|
shouldResetSync<bool>._(1007),
|
||||||
|
|
||||||
|
// Live wallpaper configuration
|
||||||
|
wallpaperPreferences<String>._(1008);
|
||||||
|
|
||||||
const StoreKey._(this.id);
|
const StoreKey._(this.id);
|
||||||
final int 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/splash_screen.page.dart';
|
||||||
import 'package:immich_mobile/pages/common/tab_controller.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/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/crop.page.dart';
|
||||||
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||||
import 'package:immich_mobile/pages/editing/filter.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: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||||
|
AutoRoute(page: LiveWallpaperSetupRoute.page, guards: [_duplicateGuard]),
|
||||||
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
||||||
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
||||||
CustomRoute(
|
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
|
/// generated route for
|
||||||
/// [LocalAlbumsPage]
|
/// [LocalAlbumsPage]
|
||||||
class LocalAlbumsRoute extends PageRouteInfo<void> {
|
class LocalAlbumsRoute extends PageRouteInfo<void> {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class DeepLinkService {
|
||||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
|
||||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||||
|
"live-wallpaper" => _buildLiveWallpaperDeepLink(link.uri.pathSegments),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -190,4 +191,11 @@ class DeepLinkService {
|
||||||
return AlbumViewerRoute(albumId: album.id);
|
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:flutter/material.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.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/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';
|
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
|
||||||
|
|
||||||
class PreferenceSetting extends StatelessWidget {
|
class PreferenceSetting extends StatelessWidget {
|
||||||
|
|
@ -8,7 +9,7 @@ class PreferenceSetting extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const preferenceSettings = [ThemeSetting(), HapticSetting()];
|
const preferenceSettings = [ThemeSetting(), HapticSetting(), LiveWallpaperSetting()];
|
||||||
|
|
||||||
return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true);
|
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