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
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")
implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version")
// Glance Configure
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

View File

@ -152,7 +152,7 @@
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget" />
android:resource="@xml/random_widget" />
</receiver>
<!-- <receiver-->
@ -165,6 +165,16 @@
<!-- android:name="android.appwidget.provider"-->
<!-- android:resource="@xml/widget" />-->
<!-- </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>

View File

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

View File

@ -1,5 +1,8 @@
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.glance.appwidget.GlanceAppWidget
@ -51,3 +54,17 @@ enum class WidgetState {
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)
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 {
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 loggedIn = true
if (imageUUID != null) {
// 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()) {
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"
/>