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