1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-06 06:07:29 +02:00

add configurable widget

This commit is contained in:
bwees
2025-07-02 10:55:20 -05:00
parent 19cf093e05
commit ba4f671069
11 changed files with 366 additions and 40 deletions

View File

@ -114,10 +114,14 @@ dependencies {
//Glance Widget //Glance Widget
implementation "androidx.glance:glance-appwidget:$compose_version" implementation "androidx.glance:glance-appwidget:$compose_version"
implementation("com.google.code.gson:gson:$gson_version") implementation "com.google.code.gson:gson:$gson_version"
implementation("io.coil-kt.coil3:coil-compose:$coil_version") // Glance Configure
implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version") implementation "androidx.activity:activity-compose:1.8.2"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
} }
// This is uncommented in F-Droid build script // This is uncommented in F-Droid build script

View File

@ -152,7 +152,7 @@
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/widget" /> android:resource="@xml/random_widget" />
</receiver> </receiver>
<!-- <receiver--> <!-- <receiver-->
@ -165,6 +165,16 @@
<!-- android:name="android.appwidget.provider"--> <!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/widget" />--> <!-- android:resource="@xml/widget" />-->
<!-- </receiver>--> <!-- </receiver>-->
<activity android:name=".widget.configure.RandomConfigure"
android:exported="true"
android:theme="@style/Theme.Material3.DayNight.NoActionBar">
>
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
</application> </application>

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.* import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
@ -15,7 +14,6 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
@ -28,23 +26,27 @@ class ImageDownloadWorker(
private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName
fun enqueue(context: Context, appWidgetId: Int, widgetType: WidgetType) { 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 manager = WorkManager.getInstance(context)
val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>( val workRequest = PeriodicWorkRequestBuilder<ImageDownloadWorker>(
20, TimeUnit.MINUTES 20, TimeUnit.MINUTES
) )
.setConstraints( .setConstraints(buildConstraints())
Constraints.Builder() .setInputData(buildInputData(appWidgetId, widgetType))
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(
Data.Builder()
.putString("widgetType", widgetType.toString())
.putInt("widgetId", appWidgetId)
.build()
)
.addTag(appWidgetId.toString()) .addTag(appWidgetId.toString())
.build() .build()
@ -55,21 +57,35 @@ class ImageDownloadWorker(
) )
} }
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
)
}
fun cancel(context: Context, glanceId: GlanceId) { fun cancel(context: Context, glanceId: GlanceId) {
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId) val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(glanceId)
WorkManager.getInstance(context).cancelAllWorkByTag(appWidgetId.toString()) WorkManager.getInstance(context).cancelAllWorkByTag(appWidgetId.toString())
} }
} }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return try { return try {
val widgetType = WidgetType.valueOf(inputData.getString("config") ?: "") val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "")
val widgetId = inputData.getInt("widgetId", -1) val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = currentState[stringPreferencesKey("uuid")] val currentImgUUID = currentState[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context) val serverConfig = ImmichAPI.getServerConfig(context)
@ -112,9 +128,9 @@ class ImageDownloadWorker(
private suspend fun updateWidget(type: WidgetType, glanceId: GlanceId, imageUUID: String, widgetState: WidgetState = WidgetState.SUCCESS) { private suspend fun updateWidget(type: WidgetType, glanceId: GlanceId, imageUUID: String, widgetState: WidgetState = WidgetState.SUCCESS) {
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
prefs[longPreferencesKey("now")] = System.currentTimeMillis() prefs[kNow] = System.currentTimeMillis()
prefs[stringPreferencesKey("uuid")] = imageUUID prefs[kImageUUID] = imageUUID
prefs[stringPreferencesKey("state")] = widgetState.toString() prefs[kWidgetState] = widgetState.toString()
} }
when (type) { when (type) {
@ -126,7 +142,7 @@ class ImageDownloadWorker(
val api = ImmichAPI(serverConfig) val api = ImmichAPI(serverConfig)
val filters = SearchFilters(AssetType.IMAGE, size=1) val filters = SearchFilters(AssetType.IMAGE, size=1)
val albumId = widgetData[stringPreferencesKey("albumID")] val albumId = widgetData[kSelectedAlbum]
if (albumId != null) { if (albumId != null) {
filters.albumIds = listOf(albumId) filters.albumIds = listOf(albumId)
@ -139,13 +155,13 @@ class ImageDownloadWorker(
} }
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) { private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "widget_image_$uuid.jpg") val file = File(context.cacheDir, imageFilename(uuid))
file.delete() file.delete()
} }
private suspend fun saveImage(bitmap: Bitmap): String = withContext(Dispatchers.IO) { private suspend fun saveImage(bitmap: Bitmap): String = withContext(Dispatchers.IO) {
val uuid = UUID.randomUUID().toString() val uuid = UUID.randomUUID().toString()
val file = File(context.cacheDir, "widget_image_$uuid.jpg") val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out -> FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
} }

View File

@ -1,5 +1,8 @@
package app.alextran.immich.widget package app.alextran.immich.widget
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
@ -51,3 +54,17 @@ enum class WidgetState {
data class ServerConfig(val serverEndpoint: String, val sessionKey: 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 kShowAlbumName = booleanPreferencesKey("showAlbumName")
const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}

View File

@ -16,7 +16,7 @@ class RandomReceiver : HomeWidgetGlanceWidgetReceiver<RandomWidget>() {
super.onUpdate(context, appWidgetManager, appWidgetIds) super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID -> appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueue(context, widgetID, WidgetType.RANDOM) ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
} }
} }
} }

View File

@ -17,15 +17,15 @@ class RandomWidget : GlanceAppWidget() {
provideContent { provideContent {
val prefs = currentState<MutablePreferences>() val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[stringPreferencesKey("uuid")] val imageUUID = prefs[kImageUUID]
val subtitle: String? = prefs[stringPreferencesKey("subtitle")] val subtitle: String? = prefs[kSubtitleText]
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var loggedIn = true var loggedIn = true
if (imageUUID != null) { if (imageUUID != null) {
// fetch a random photo from server // fetch a random photo from server
val file = File(context.cacheDir, "widget_image_$imageUUID.jpg") val file = File(context.cacheDir, imageFilename(id))
if (file.exists()) { if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500) bitmap = loadScaledBitmap(file, 500, 500)

View File

@ -0,0 +1,72 @@
package app.alextran.immich.widget.configure
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.geometry.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
data class DropdownItem (
val label: String,
val id: String,
)
// Creating a composable to display a drop down menu
@Composable
fun Dropdown(items: List<DropdownItem>,
selectedItem: DropdownItem?,
onItemSelected: (DropdownItem) -> Unit,
modifier: Modifier = Modifier,
label: String = "",) {
var expanded by remember { mutableStateOf(false) }
var textFieldSize by remember { mutableStateOf(Size.Zero) }
// Toggle icon based on expanded state
val icon = if (expanded)
Icons.Filled.KeyboardArrowUp
else
Icons.Filled.KeyboardArrowDown
Column(modifier) {
OutlinedTextField(
value = selectedItem?.label ?: "",
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
},
trailingIcon = {
Icon(
imageVector = icon,
contentDescription = "Dropdown icon",
modifier = Modifier.clickable { expanded = !expanded }
)
}
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() })
) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(text = item.label) },
onClick = {
onItemSelected(item)
expanded = false
}
)
}
}
}
}

View File

@ -0,0 +1,30 @@
package app.alextran.immich.widget.configure
import android.os.Build
import androidx.compose.foundation.*
import androidx.compose.material3.*
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun LightDarkTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(), // ← This line is key
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
)
}

View File

@ -0,0 +1,173 @@
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.Close
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.WidgetState
import app.alextran.immich.widget.WidgetType
import app.alextran.immich.widget.kSelectedAlbum
import app.alextran.immich.widget.kShowAlbumName
import kotlinx.coroutines.launch
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(WidgetState.LOADING) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
val api: ImmichAPI?
if (serverCfg == null) {
state = WidgetState.LOG_IN
return@LaunchedEffect
}
api = ImmichAPI(serverCfg)
val albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}
availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems
state = WidgetState.SUCCESS
// load selected configuration
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum]
val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId }
selectedAlbum = albumEntity ?: availableAlbums.first()
// load showAlbumName
showAlbumName = currentState[kShowAlbumName] ?: false
}
suspend fun saveConfiguration() {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kSelectedAlbum] = selectedAlbum?.id ?: ""
prefs[kShowAlbumName] = showAlbumName
}
ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM)
}
Scaffold(
topBar = {
TopAppBar (
title = { Text("Widget Configuration") },
navigationIcon = {
IconButton(onClick = {
scope.launch {
saveConfiguration()
onDone()
}
}) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
)
}
) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding), // Respect the top bar
color = MaterialTheme.colorScheme.background
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
when (state) {
WidgetState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp))
WidgetState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.")
WidgetState.SUCCESS -> {
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)
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 },
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Show Album Name")
Switch(
checked = showAlbumName,
onCheckedChange = { showAlbumName = it }
)
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,11 @@
<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"
/>

View File

@ -1,7 +0,0 @@
<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"
/>