mirror of https://github.com/immich-app/immich.git
feat(mobile): android widgets (#19310)
* wip * wip widgets * more wip changes * latest changes * working random widget * cleanup * add configurable widget * add memory widget and cleanup of codebase * album name handling * add deeplinks * finish minor refactoring and add some polish :) * fix single shot type on random widget Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * switch to ExposedDropdownMenuBox for random configure activity * handle empty album and no connection edge cases * android project cleanup * fix proguard and gson issues * fix deletion handling * fix proguard stripping for widget model classes/enums * change random configuration activity close to a checkmark on right side --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>pull/20002/head
parent
7bae49ebd5
commit
f32d4f15b6
@ -0,0 +1,33 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, options)
|
||||||
|
|
||||||
|
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
|
||||||
|
options.inJustDecodeBounds = false
|
||||||
|
|
||||||
|
return BitmapFactory.decodeFile(file.absolutePath, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
||||||
|
val (height: Int, width: Int) = options.run { outHeight to outWidth }
|
||||||
|
var inSampleSize = 1
|
||||||
|
|
||||||
|
if (height > reqHeight || width > reqWidth) {
|
||||||
|
val halfHeight: Int = height / 2
|
||||||
|
val halfWidth: Int = width / 2
|
||||||
|
|
||||||
|
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
|
||||||
|
inSampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inSampleSize
|
||||||
|
}
|
||||||
@ -0,0 +1,241 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.glance.*
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.work.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class ImageDownloadWorker(
|
||||||
|
private val context: Context,
|
||||||
|
workerParameters: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, workerParameters) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
|
||||||
|
|
||||||
|
private fun buildConstraints(): Constraints {
|
||||||
|
return Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data {
|
||||||
|
return Data.Builder()
|
||||||
|
.putString(kWorkerWidgetType, widgetType.toString())
|
||||||
|
.putInt(kWorkerWidgetID, appWidgetId)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) {
|
||||||
|
val manager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
|
||||||
|
20, TimeUnit.MINUTES
|
||||||
|
)
|
||||||
|
.setConstraints(buildConstraints())
|
||||||
|
.setInputData(buildInputData(appWidgetId, widgetType))
|
||||||
|
.addTag(appWidgetId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
manager.enqueueUniquePeriodicWork(
|
||||||
|
"$uniqueWorkName-$appWidgetId",
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) {
|
||||||
|
val manager = WorkManager.getInstance(context)
|
||||||
|
|
||||||
|
val workRequest = OneTimeWorkRequestBuilder<ImageDownloadWorker>()
|
||||||
|
.setConstraints(buildConstraints())
|
||||||
|
.setInputData(buildInputData(appWidgetId, widgetType))
|
||||||
|
.addTag(appWidgetId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
manager.enqueueUniqueWork(
|
||||||
|
"$uniqueWorkName-$appWidgetId",
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cancel(context: Context, appWidgetId: Int) {
|
||||||
|
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
|
||||||
|
|
||||||
|
// delete cached image
|
||||||
|
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||||
|
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||||
|
val currentImgUUID = widgetConfig[kImageUUID]
|
||||||
|
|
||||||
|
if (!currentImgUUID.isNullOrEmpty()) {
|
||||||
|
val file = File(context.cacheDir, imageFilename(currentImgUUID))
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
return try {
|
||||||
|
val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
|
||||||
|
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
|
||||||
|
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
|
||||||
|
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||||
|
val currentImgUUID = widgetConfig[kImageUUID]
|
||||||
|
|
||||||
|
val serverConfig = ImmichAPI.getServerConfig(context)
|
||||||
|
|
||||||
|
// clear any image caches and go to "login" state if no credentials
|
||||||
|
if (serverConfig == null) {
|
||||||
|
if (!currentImgUUID.isNullOrEmpty()) {
|
||||||
|
deleteImage(currentImgUUID)
|
||||||
|
updateWidget(
|
||||||
|
glanceId,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"immich://",
|
||||||
|
WidgetState.LOG_IN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch new image
|
||||||
|
val entry = when (widgetType) {
|
||||||
|
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
|
||||||
|
WidgetType.MEMORIES -> fetchMemory(serverConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear current image if it exists
|
||||||
|
if (!currentImgUUID.isNullOrEmpty()) {
|
||||||
|
deleteImage(currentImgUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// save a new image
|
||||||
|
val imgUUID = UUID.randomUUID().toString()
|
||||||
|
saveImage(entry.image, imgUUID)
|
||||||
|
|
||||||
|
// trigger the update routine with new image uuid
|
||||||
|
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(uniqueWorkName, "Error while loading image", e)
|
||||||
|
if (runAttemptCount < 10) {
|
||||||
|
Result.retry()
|
||||||
|
} else {
|
||||||
|
Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateWidget(
|
||||||
|
glanceId: GlanceId,
|
||||||
|
imageUUID: String,
|
||||||
|
subtitle: String?,
|
||||||
|
deeplink: String?,
|
||||||
|
widgetState: WidgetState = WidgetState.SUCCESS
|
||||||
|
) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs[kNow] = System.currentTimeMillis()
|
||||||
|
prefs[kImageUUID] = imageUUID
|
||||||
|
prefs[kWidgetState] = widgetState.toString()
|
||||||
|
prefs[kSubtitleText] = subtitle ?: ""
|
||||||
|
prefs[kDeeplinkURL] = deeplink ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoWidget().update(context,glanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchRandom(
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
widgetConfig: Preferences
|
||||||
|
): WidgetEntry {
|
||||||
|
val api = ImmichAPI(serverConfig)
|
||||||
|
|
||||||
|
val filters = SearchFilters(AssetType.IMAGE)
|
||||||
|
val albumId = widgetConfig[kSelectedAlbum]
|
||||||
|
val showSubtitle = widgetConfig[kShowAlbumName]
|
||||||
|
val albumName = widgetConfig[kSelectedAlbumName]
|
||||||
|
var subtitle: String? = if (showSubtitle == true) albumName else ""
|
||||||
|
|
||||||
|
if (albumId != null) {
|
||||||
|
filters.albumIds = listOf(albumId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var randomSearch = api.fetchSearchResults(filters)
|
||||||
|
|
||||||
|
// handle an empty album, fallback to random
|
||||||
|
if (randomSearch.isEmpty() && albumId != null) {
|
||||||
|
randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE))
|
||||||
|
subtitle = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val random = randomSearch.first()
|
||||||
|
val image = api.fetchImage(random)
|
||||||
|
|
||||||
|
return WidgetEntry(
|
||||||
|
image,
|
||||||
|
subtitle,
|
||||||
|
assetDeeplink(random)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchMemory(
|
||||||
|
serverConfig: ServerConfig
|
||||||
|
): WidgetEntry {
|
||||||
|
val api = ImmichAPI(serverConfig)
|
||||||
|
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val memories = api.fetchMemory(today)
|
||||||
|
val asset: Asset
|
||||||
|
var subtitle: String? = null
|
||||||
|
|
||||||
|
if (memories.isNotEmpty()) {
|
||||||
|
// pick a random asset from a random memory
|
||||||
|
val memory = memories.random()
|
||||||
|
asset = memory.assets.random()
|
||||||
|
|
||||||
|
val yearDiff = today.year - memory.data.year
|
||||||
|
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
|
||||||
|
} else {
|
||||||
|
val filters = SearchFilters(AssetType.IMAGE, size=1)
|
||||||
|
asset = api.fetchSearchResults(filters).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
val image = api.fetchImage(asset)
|
||||||
|
return WidgetEntry(
|
||||||
|
image,
|
||||||
|
subtitle,
|
||||||
|
assetDeeplink(asset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(context.cacheDir, imageFilename(uuid))
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(context.cacheDir, imageFilename(uuid))
|
||||||
|
FileOutputStream(file).use { out ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class ImmichAPI(cfg: ServerConfig) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getServerConfig(context: Context): ServerConfig? {
|
||||||
|
val prefs = HomeWidgetPlugin.getData(context)
|
||||||
|
|
||||||
|
val serverURL = prefs.getString("widget_server_url", "") ?: ""
|
||||||
|
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
|
||||||
|
|
||||||
|
if (serverURL.isBlank() || sessionKey.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServerConfig(
|
||||||
|
serverURL,
|
||||||
|
sessionKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val serverConfig = cfg
|
||||||
|
|
||||||
|
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
|
||||||
|
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
|
||||||
|
|
||||||
|
for ((key, value) in params) {
|
||||||
|
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL(urlString.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
|
||||||
|
val url = buildRequestURL("/search/random")
|
||||||
|
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
doOutput = true
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.outputStream.use {
|
||||||
|
OutputStreamWriter(it).use { writer ->
|
||||||
|
writer.write(gson.toJson(filters))
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = connection.inputStream.bufferedReader().readText()
|
||||||
|
val type = object : TypeToken<List<Asset>>() {}.type
|
||||||
|
gson.fromJson(response, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
|
||||||
|
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||||
|
val url = buildRequestURL("/memories", listOf("for" to iso8601))
|
||||||
|
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = connection.inputStream.bufferedReader().readText()
|
||||||
|
val type = object : TypeToken<List<MemoryResult>>() {}.type
|
||||||
|
gson.fromJson(response, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
|
||||||
|
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
|
||||||
|
val connection = url.openConnection()
|
||||||
|
val data = connection.getInputStream().readBytes()
|
||||||
|
BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||||
|
?: throw Exception("Invalid image data")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
|
||||||
|
val url = buildRequestURL("/albums")
|
||||||
|
val connection = (url.openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = connection.inputStream.bufferedReader().readText()
|
||||||
|
val type = object : TypeToken<List<Album>>() {}.type
|
||||||
|
gson.fromJson(response, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MemoryReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget = PhotoWidget()
|
||||||
|
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
|
||||||
|
appWidgetIds.forEach { widgetID ->
|
||||||
|
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||||
|
|
||||||
|
// Launch coroutine to setup a single shot if the app requested the update
|
||||||
|
if (fromMainApp) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
val provider = ComponentName(context, MemoryReceiver::class.java)
|
||||||
|
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||||
|
|
||||||
|
glanceIds.forEach { widgetID ->
|
||||||
|
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
appWidgetIds.forEach { id ->
|
||||||
|
ImageDownloadWorker.cancel(context, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.glance.appwidget.*
|
||||||
|
import androidx.glance.*
|
||||||
|
import androidx.glance.action.clickable
|
||||||
|
import androidx.glance.layout.*
|
||||||
|
import androidx.glance.state.GlanceStateDefinition
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import app.alextran.immich.R
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class PhotoWidget : GlanceAppWidget() {
|
||||||
|
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
provideContent {
|
||||||
|
val prefs = currentState<MutablePreferences>()
|
||||||
|
|
||||||
|
val imageUUID = prefs[kImageUUID]
|
||||||
|
val subtitle = prefs[kSubtitleText]
|
||||||
|
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
|
||||||
|
val widgetState = prefs[kWidgetState]
|
||||||
|
var bitmap: Bitmap? = null
|
||||||
|
|
||||||
|
if (imageUUID != null) {
|
||||||
|
// fetch a random photo from server
|
||||||
|
val file = File(context.cacheDir, imageFilename(imageUUID))
|
||||||
|
|
||||||
|
if (file.exists()) {
|
||||||
|
bitmap = loadScaledBitmap(file, 500, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WIDGET CONTENT
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(GlanceTheme.colors.background)
|
||||||
|
.clickable {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri())
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (bitmap != null) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(bitmap),
|
||||||
|
contentDescription = "Widget Image",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = GlanceModifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!subtitle.isNullOrBlank()) {
|
||||||
|
Column(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = TextStyle(
|
||||||
|
color = ColorProvider(Color.White),
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.background(ColorProvider(Color(0x99000000))) // 60% black
|
||||||
|
.padding(8.dp)
|
||||||
|
.cornerRadius(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.splash),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (widgetState == WidgetState.LOG_IN.toString()) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = GlanceModifier.fillMaxWidth().padding(16.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = GlanceModifier.size(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||||
|
|
||||||
|
Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package app.alextran.immich.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import es.antonborri.home_widget.HomeWidgetPlugin
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RandomReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
override val glanceAppWidget = PhotoWidget()
|
||||||
|
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
|
||||||
|
appWidgetIds.forEach { widgetID ->
|
||||||
|
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
|
||||||
|
|
||||||
|
// Launch coroutine to setup a single shot if the app requested the update
|
||||||
|
if (fromMainApp) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
val provider = ComponentName(context, RandomReceiver::class.java)
|
||||||
|
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
|
||||||
|
|
||||||
|
glanceIds.forEach { widgetID ->
|
||||||
|
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
appWidgetIds.forEach { id ->
|
||||||
|
ImageDownloadWorker.cancel(context, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package app.alextran.immich.widget.configure
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.*
|
||||||
|
|
||||||
|
|
||||||
|
data class DropdownItem (
|
||||||
|
val label: String,
|
||||||
|
val id: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Creating a composable to display a drop down menu
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun Dropdown(items: List<DropdownItem>,
|
||||||
|
selectedItem: DropdownItem?,
|
||||||
|
onItemSelected: (DropdownItem) -> Unit,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = !expanded && enabled },
|
||||||
|
) {
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = selectedOption,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
enabled = enabled,
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||||
|
},
|
||||||
|
colors = ExposedDropdownMenuDefaults.textFieldColors(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor()
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
items.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) },
|
||||||
|
onClick = {
|
||||||
|
selectedOption = option.label
|
||||||
|
onItemSelected(option)
|
||||||
|
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package app.alextran.immich.widget.configure
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LightDarkTheme(
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
val colorScheme = when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme ->
|
||||||
|
dynamicDarkColorScheme(context)
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme ->
|
||||||
|
dynamicLightColorScheme(context)
|
||||||
|
isDarkTheme -> darkColorScheme()
|
||||||
|
else -> lightColorScheme()
|
||||||
|
}
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
package app.alextran.immich.widget.configure
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import app.alextran.immich.widget.ImageDownloadWorker
|
||||||
|
import app.alextran.immich.widget.ImmichAPI
|
||||||
|
import app.alextran.immich.widget.model.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
class RandomConfigure : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Get widget ID from intent
|
||||||
|
val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
|
AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||||
|
?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val glanceId = GlanceAppWidgetManager(applicationContext)
|
||||||
|
.getGlanceIdBy(appWidgetId)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
LightDarkTheme {
|
||||||
|
RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = {
|
||||||
|
finish()
|
||||||
|
Log.w("WIDGET_ACTIVITY", "SAVING")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) {
|
||||||
|
|
||||||
|
var selectedAlbum by remember { mutableStateOf<DropdownItem?>(null) }
|
||||||
|
var showAlbumName by remember { mutableStateOf(false) }
|
||||||
|
var availableAlbums by remember { mutableStateOf<List<DropdownItem>>(listOf()) }
|
||||||
|
var state by remember { mutableStateOf(WidgetConfigState.LOADING) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// get albums from server
|
||||||
|
val serverCfg = ImmichAPI.getServerConfig(context)
|
||||||
|
|
||||||
|
if (serverCfg == null) {
|
||||||
|
state = WidgetConfigState.LOG_IN
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val api = ImmichAPI(serverCfg)
|
||||||
|
|
||||||
|
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
|
||||||
|
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
|
||||||
|
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
|
||||||
|
var albumItems: List<DropdownItem>
|
||||||
|
|
||||||
|
try {
|
||||||
|
albumItems = api.fetchAlbums().map {
|
||||||
|
DropdownItem(it.albumName, it.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = WidgetConfigState.SUCCESS
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Log.e("WidgetWorker", "Error fetching albums: ${e.message}")
|
||||||
|
|
||||||
|
state = WidgetConfigState.NO_CONNECTION
|
||||||
|
albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId))
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems
|
||||||
|
|
||||||
|
// load selected configuration
|
||||||
|
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
|
||||||
|
selectedAlbum = albumEntity ?: availableAlbums.first()
|
||||||
|
|
||||||
|
// load showAlbumName
|
||||||
|
showAlbumName = currentState[kShowAlbumName] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveConfiguration() {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
|
||||||
|
prefs[kSelectedAlbumName] = selectedAlbum?.label ?: ""
|
||||||
|
prefs[kShowAlbumName] = showAlbumName
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar (
|
||||||
|
title = { Text("Widget Configuration") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
saveConfiguration()
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding), // Respect the top bar
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||||
|
when (state) {
|
||||||
|
WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||||
|
WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
|
||||||
|
else -> {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
|
// no connection warning
|
||||||
|
if (state == WidgetConfigState.NO_CONNECTION) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = "Warning",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "No connection to the server is available. Please try again later.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Album")
|
||||||
|
Dropdown(
|
||||||
|
items = availableAlbums,
|
||||||
|
selectedItem = selectedAlbum,
|
||||||
|
onItemSelected = { selectedAlbum = it },
|
||||||
|
enabled = (state != WidgetConfigState.NO_CONNECTION)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = "Show Album Name")
|
||||||
|
Switch(
|
||||||
|
checked = showAlbumName,
|
||||||
|
onCheckedChange = { showAlbumName = it },
|
||||||
|
enabled = (state != WidgetConfigState.NO_CONNECTION)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package app.alextran.immich.widget.model
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.datastore.preferences.core.*
|
||||||
|
|
||||||
|
// MARK: Immich Entities
|
||||||
|
|
||||||
|
enum class AssetType {
|
||||||
|
IMAGE, VIDEO, AUDIO, OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Asset(
|
||||||
|
val id: String,
|
||||||
|
val type: AssetType,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SearchFilters(
|
||||||
|
var type: AssetType = AssetType.IMAGE,
|
||||||
|
val size: Int = 1,
|
||||||
|
var albumIds: List<String> = listOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemoryResult(
|
||||||
|
val id: String,
|
||||||
|
var assets: List<Asset>,
|
||||||
|
val type: String,
|
||||||
|
val data: MemoryData
|
||||||
|
) {
|
||||||
|
data class MemoryData(val year: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Album(
|
||||||
|
val id: String,
|
||||||
|
val albumName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: Widget Specific
|
||||||
|
|
||||||
|
enum class WidgetType {
|
||||||
|
RANDOM, MEMORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WidgetState {
|
||||||
|
LOADING, SUCCESS, LOG_IN;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class WidgetConfigState {
|
||||||
|
LOADING, SUCCESS, LOG_IN, NO_CONNECTION
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WidgetEntry (
|
||||||
|
val image: Bitmap,
|
||||||
|
val subtitle: String?,
|
||||||
|
val deeplink: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServerConfig(val serverEndpoint: String, val sessionKey: String)
|
||||||
|
|
||||||
|
// MARK: Widget State Keys
|
||||||
|
val kImageUUID = stringPreferencesKey("uuid")
|
||||||
|
val kSubtitleText = stringPreferencesKey("subtitle")
|
||||||
|
val kNow = longPreferencesKey("now")
|
||||||
|
val kWidgetState = stringPreferencesKey("state")
|
||||||
|
val kSelectedAlbum = stringPreferencesKey("albumID")
|
||||||
|
val kSelectedAlbumName = stringPreferencesKey("albumName")
|
||||||
|
val kShowAlbumName = booleanPreferencesKey("showAlbumName")
|
||||||
|
val kDeeplinkURL = stringPreferencesKey("deeplink")
|
||||||
|
|
||||||
|
const val kWorkerWidgetType = "widgetType"
|
||||||
|
const val kWorkerWidgetID = "widgetId"
|
||||||
|
const val kTriggeredFromApp = "triggeredFromApp"
|
||||||
|
|
||||||
|
fun imageFilename(id: String): String {
|
||||||
|
return "widget_image_$id.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assetDeeplink(asset: Asset): String {
|
||||||
|
return "immich://asset?id=${asset.id}"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<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>
|
||||||
|
</resources>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="110dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1200000"
|
||||||
|
android:description="@string/memory_widget_description"
|
||||||
|
android:previewImage="@drawable/memory_preview"
|
||||||
|
/>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout"
|
||||||
|
android:minWidth="110dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="1200000"
|
||||||
|
android:configure="app.alextran.immich.widget.configure.RandomConfigure"
|
||||||
|
android:widgetFeatures="reconfigurable|configuration_optional"
|
||||||
|
tools:targetApi="28"
|
||||||
|
android:description="@string/random_widget_description"
|
||||||
|
android:previewImage="@drawable/random_preview"
|
||||||
|
/>
|
||||||
Loading…
Reference in New Issue