diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle
index 3b52984b9a..4bb3fc0478 100644
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -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
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index e9b8a3b6d2..3fcc44ad3f 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -152,7 +152,7 @@
+ android:resource="@xml/random_widget" />
@@ -165,6 +165,16 @@
+
+
+ >
+
+
+
+
+
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt
index 6c159d734e..28a8d536a0 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt
@@ -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(
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()
+ .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)
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt
index 0db93a03c6..22ff710d55 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/Model.kt
@@ -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"
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt
index 8041a803e1..e7b2291a12 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt
@@ -16,7 +16,7 @@ class RandomReceiver : HomeWidgetGlanceWidgetReceiver() {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
- ImageDownloadWorker.enqueue(context, widgetID, WidgetType.RANDOM)
+ ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt
index 68c277c3d3..c6fbf8be99 100644
--- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomWidget.kt
@@ -17,15 +17,15 @@ class RandomWidget : GlanceAppWidget() {
provideContent {
val prefs = currentState()
- 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)
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt
new file mode 100644
index 0000000000..3fd8e9a744
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt
@@ -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,
+ 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
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt
new file mode 100644
index 0000000000..a7035d3f1c
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt
@@ -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
+ )
+}
diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt
new file mode 100644
index 0000000000..b5f6f0b8b9
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt
@@ -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(null) }
+ var showAlbumName by remember { mutableStateOf(false) }
+ var availableAlbums by remember { mutableStateOf>(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 }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/mobile/android/app/src/main/res/xml/random_widget.xml b/mobile/android/app/src/main/res/xml/random_widget.xml
new file mode 100644
index 0000000000..2c936a0dbd
--- /dev/null
+++ b/mobile/android/app/src/main/res/xml/random_widget.xml
@@ -0,0 +1,11 @@
+
diff --git a/mobile/android/app/src/main/res/xml/widget.xml b/mobile/android/app/src/main/res/xml/widget.xml
deleted file mode 100644
index 7b461bf324..0000000000
--- a/mobile/android/app/src/main/res/xml/widget.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
\ No newline at end of file