diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 0d25c658e0..f321314611 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -146,7 +146,8 @@ + android:exported="true" + android:label="@string/random_widget_title"> @@ -157,7 +158,8 @@ + android:exported="true" + android:label="@string/memory_widget_title"> 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 07d063cc3e..b57f9b535e 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 @@ -194,7 +194,8 @@ class ImageDownloadWorker( val memory = memories.random() asset = memory.assets.random() - subtitle = "${today.year - memory.data.year} years ago" + 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() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt index 2d9aad0b68..4b6434d420 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt @@ -1,7 +1,6 @@ package app.alextran.immich.widget import android.content.Context -import android.content.SharedPreferences import android.graphics.Bitmap import android.graphics.BitmapFactory import com.google.gson.Gson @@ -13,11 +12,8 @@ import java.io.OutputStreamWriter import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder -import java.text.SimpleDateFormat import java.time.LocalDate import java.time.format.DateTimeFormatter -import java.util.* - class ImmichAPI(cfg: ServerConfig) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt index 46a8972258..f4cb3323d2 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt @@ -1,10 +1,16 @@ package app.alextran.immich.widget -import HomeWidgetGlanceWidgetReceiver import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import es.antonborri.home_widget.HomeWidgetPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -class MemoryReceiver : HomeWidgetGlanceWidgetReceiver() { +class MemoryReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget = PhotoWidget() override fun onUpdate( @@ -18,5 +24,23 @@ class MemoryReceiver : HomeWidgetGlanceWidgetReceiver() { 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) + } } 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 9aaeec8980..fc8f3828a0 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 @@ -68,6 +68,7 @@ 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" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt index c41c5b374a..04c7838c8b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -3,6 +3,9 @@ package app.alextran.immich.widget import android.content.Context import android.content.Intent import android.graphics.Bitmap +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.* import androidx.core.net.toUri @@ -14,6 +17,7 @@ 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 @@ -23,71 +27,101 @@ class PhotoWidget : GlanceAppWidget() { override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - val prefs = currentState() + provideContent { + val prefs = currentState() - val imageUUID = prefs[kImageUUID] - val subtitle = prefs[kSubtitleText] - val deeplinkURL = prefs[kDeeplinkURL]?.toUri() - var bitmap: Bitmap? = null + 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 (imageUUID != null) { + // fetch a random photo from server + val file = File(context.cacheDir, imageFilename(imageUUID)) - if (file.exists()) { - bitmap = loadScaledBitmap(file, 500, 500) - } + if (file.exists()) { + bitmap = loadScaledBitmap(file, 500, 500) } + } - // WIDGET CONTENT - Box( - modifier = GlanceModifier - .fillMaxSize() - .background(Color.White) - .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() - ) + // 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, + 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 - .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) - ) - } + .background(ColorProvider(Color(0x99000000))) // 60% black + .padding(8.dp) + .cornerRadius(8.dp) + ) } - } else { + } + } 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)) + } + } } } } + } } override suspend fun onDelete(context: Context, glanceId: GlanceId) { 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 687cb87b36..30fb01afbd 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 @@ -1,11 +1,16 @@ package app.alextran.immich.widget -import HomeWidgetGlanceWidgetReceiver import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context -import android.util.Log +import android.content.Intent +import es.antonborri.home_widget.HomeWidgetPlugin +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -class RandomReceiver : HomeWidgetGlanceWidgetReceiver() { +class RandomReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget = PhotoWidget() override fun onUpdate( @@ -19,5 +24,23 @@ class RandomReceiver : HomeWidgetGlanceWidgetReceiver() { 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.MEMORIES) + } + } + } + + super.onReceive(context, intent) + } } 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 index a7035d3f1c..4dfa4bd745 100644 --- 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 @@ -9,7 +9,6 @@ import androidx.compose.ui.platform.LocalContext @Composable fun LightDarkTheme( - useDarkTheme: Boolean = isSystemInDarkTheme(), // ← This line is key content: @Composable () -> Unit ) { val context = LocalContext.current diff --git a/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png new file mode 100644 index 0000000000..97aceb3ef6 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png differ diff --git a/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png new file mode 100644 index 0000000000..f94d1bbcd5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png differ diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..5ac495ebb5 --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Memories + Random + + See memories from Immich. + View a random image from your library or a specific album. + diff --git a/mobile/android/app/src/main/res/xml/memory_widget.xml b/mobile/android/app/src/main/res/xml/memory_widget.xml index 84847f6bd6..611c5aae02 100644 --- a/mobile/android/app/src/main/res/xml/memory_widget.xml +++ b/mobile/android/app/src/main/res/xml/memory_widget.xml @@ -4,4 +4,6 @@ android:minHeight="110dp" android:resizeMode="horizontal|vertical" android:updatePeriodMillis="1200000" + android:description="@string/memory_widget_description" + android:previewImage="@drawable/memory_preview" /> diff --git a/mobile/android/app/src/main/res/xml/random_widget.xml b/mobile/android/app/src/main/res/xml/random_widget.xml index 2c936a0dbd..25fb24754f 100644 --- a/mobile/android/app/src/main/res/xml/random_widget.xml +++ b/mobile/android/app/src/main/res/xml/random_widget.xml @@ -8,4 +8,6 @@ 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" />