From c1c76252f1b8692b8fdf8033bacae9ff09135de8 Mon Sep 17 00:00:00 2001 From: Louis Date: Sat, 17 Oct 2026 13:52:42 +0200 Subject: [PATCH] build: S12 widgets (now, forecast) Co-Authored-By: Claude Opus 5.5 --- .../main/java/app/wetter/widget/WidgetArt.kt | 34 +++ .../main/java/app/wetter/widget/Widgets.kt | 242 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt create mode 100644 wetter/app/src/main/java/app/wetter/widget/Widgets.kt diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt new file mode 100644 index 0000000..b62afab --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt @@ -0,0 +1,34 @@ +package app.wetter.widget + +import android.graphics.Bitmap +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import app.wetter.data.Condition +import app.wetter.ui.scene.Particles +import app.wetter.ui.scene.SceneSpec +import app.wetter.ui.scene.drawSceneLayers +import app.wetter.ui.scene.drawWeatherIcon + +/** Widgets cannot run shaders or animations, so the scene and icons are rendered to bitmaps. */ +object WidgetArt { + private fun render(w: Int, h: Int, block: DrawScope.() -> Unit): Bitmap { + val image = ImageBitmap(w, h) + CanvasDrawScope().draw(Density(1f), LayoutDirection.Ltr, Canvas(image), Size(w.toFloat(), h.toFloat()), block) + return image.asAndroidBitmap() + } + + fun icon(condition: Condition, isDay: Boolean, px: Int = 144): Bitmap = render(px, px) { drawWeatherIcon(condition, isDay) } + + /** Still frame of the sky, small enough for the RemoteViews size limit. */ + fun scene(spec: SceneSpec, w: Int = 480, h: Int = 320): Bitmap = render(w, h) { + drawRect(Brush.verticalGradient(listOf(spec.top, spec.bottom))) + drawSceneLayers(spec, t = .2f, scroll = 0f, particles = Particles.make(spec.condition)) + } +} diff --git a/wetter/app/src/main/java/app/wetter/widget/Widgets.kt b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt new file mode 100644 index 0000000..c1212c2 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt @@ -0,0 +1,242 @@ +package app.wetter.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.ActionParameters +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.updateAll +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider as FixedColor +import app.wetter.MainActivity +import app.wetter.R +import app.wetter.data.Forecast +import app.wetter.data.Fmt +import app.wetter.data.Graph +import app.wetter.data.LocationEntity +import app.wetter.data.Settings +import app.wetter.data.Units +import app.wetter.data.conditionOf +import app.wetter.data.intensityOf +import app.wetter.ui.components.conditionLabel +import app.wetter.ui.scene.SceneSpec +import app.wetter.ui.theme.WRadius +import app.wetter.work.RefreshWorker +import kotlinx.coroutines.flow.first +import java.time.LocalDate + +suspend fun updateAllWidgets(context: Context) { + NowWidget().updateAll(context) + ForecastWidget().updateAll(context) +} + +private class WidgetData(val location: LocationEntity?, val forecast: Forecast?, val units: Units) + +private suspend fun load(context: Context, id: GlanceId): WidgetData { + Graph.init(context) + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + val location = Graph.repo.widgetLocation(appWidgetId) + val forecast = location?.let { Graph.repo.forecastOnce(it.id) } + val settings = runCatching { Graph.settings.settings.first() }.getOrDefault(Settings()) + return WidgetData(location, forecast, Units(settings)) +} + +class RefreshAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + RefreshWorker.runNow(context) + } +} + +/** Shared frame: scene bitmap behind, rounded, tap opens the city. */ +@Composable +private fun Frame(data: WidgetData, content: @Composable (SceneSpec) -> Unit) { + val context = LocalContext.current + val f = data.forecast + val spec = f?.let { SceneSpec(conditionOf(it.current.code), it.current.isDay, intensityOf(it.current.code)) } + ?: SceneSpec(app.wetter.data.Condition.CLOUDY, true) + Box( + GlanceModifier.fillMaxSize().cornerRadius(WRadius.widget) + .clickable(actionStartActivity(MainActivity.intent(context, data.location?.id))), + ) { + Image(ImageProvider(WidgetArt.scene(spec)), null, GlanceModifier.fillMaxSize(), contentScale = ContentScale.FillBounds) + content(spec) + } +} + +private fun text(color: Color, size: Int, weight: FontWeight = FontWeight.Normal, align: TextAlign = TextAlign.Start) = + TextStyle(color = FixedColor(color), fontSize = size.sp, fontWeight = weight, textAlign = align) + +/** 2×1 and 2×2: icon + temperature, larger adds city, condition, high/low. */ +class NowWidget : GlanceAppWidget() { + override val sizeMode = SizeMode.Responsive(setOf(SMALL, MEDIUM)) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val data = load(context, id) + provideContent { Content(data) } + } + + @Composable + private fun Content(data: WidgetData) { + val context = LocalContext.current + val size = LocalSize.current + Frame(data) { spec -> + val fg = spec.onScene + val f = data.forecast + if (f == null) { + Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.widget_loading), style = text(fg, 16)) + } + return@Frame + } + val u = data.units + val cond = conditionOf(f.current.code) + val condText = context.getString(conditionLabel(cond, f.current.isDay)) + val name = data.location?.name?.ifBlank { context.getString(R.string.current_location) } ?: "" + val summary = "$name ${u.temp(f.current.temp)} $condText" + if (size.height < MEDIUM.height) { + Row( + GlanceModifier.fillMaxSize().padding(horizontal = 16.dp).semantics { contentDescription = summary }, + verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(40.dp)) + Spacer(GlanceModifier.width(8.dp)) + Text(u.temp(f.current.temp), style = text(fg, 36, FontWeight.Bold)) + } + } else { + Column(GlanceModifier.fillMaxSize().padding(16.dp).semantics { contentDescription = summary }) { + Text(name, style = text(fg, 14, FontWeight.Medium), maxLines = 1) + Spacer(GlanceModifier.defaultWeight()) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(u.temp(f.current.temp), style = text(fg, 44, FontWeight.Bold)) + Spacer(GlanceModifier.width(6.dp)) + Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(40.dp)) + } + Text(condText, style = text(fg, 13), maxLines = 1) + f.today?.let { Text("↑ ${u.temp(it.tMax)} ↓ ${u.temp(it.tMin)}", style = text(fg, 12)) } + } + } + } + } + + companion object { + val SMALL = DpSize(110.dp, 48.dp) + val MEDIUM = DpSize(110.dp, 110.dp) + } +} + +/** 4×2 hourly, 4×3 and up adds the next days. */ +class ForecastWidget : GlanceAppWidget() { + override val sizeMode = SizeMode.Responsive(setOf(WIDE, TALL)) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val data = load(context, id) + provideContent { Content(data) } + } + + @Composable + private fun Content(data: WidgetData) { + val context = LocalContext.current + val size = LocalSize.current + Frame(data) { spec -> + val fg = spec.onScene + val f = data.forecast + if (f == null) { + Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.widget_loading), style = text(fg, 16)) + } + return@Frame + } + val u = data.units + val cond = conditionOf(f.current.code) + val name = data.location?.name?.ifBlank { context.getString(R.string.current_location) } ?: "" + Column(GlanceModifier.fillMaxSize().padding(16.dp)) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(GlanceModifier.defaultWeight()) { + Text(name, style = text(fg, 14, FontWeight.Medium), maxLines = 1) + Text(context.getString(conditionLabel(cond, f.current.isDay)), style = text(fg, 12), maxLines = 1) + } + Text(u.temp(f.current.temp), style = text(fg, 36, FontWeight.Bold)) + Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(36.dp)) + Image( + ImageProvider(R.drawable.ic_refresh), context.getString(R.string.retry), + GlanceModifier.size(28.dp).padding(4.dp).clickable(actionRunCallback()), + colorFilter = androidx.glance.ColorFilter.tint(FixedColor(fg)), + ) + } + Spacer(GlanceModifier.height(10.dp)) + Row(GlanceModifier.fillMaxWidth()) { + f.upcomingHours(6).forEachIndexed { i, h -> + Column(GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.CenterHorizontally) { + Text(if (i == 0) context.getString(R.string.now) else Fmt.hourLabel(h.time), style = text(fg, 11, align = TextAlign.Center)) + Image(ImageProvider(WidgetArt.icon(conditionOf(h.code), h.isDay, 96)), null, GlanceModifier.size(26.dp)) + Text(u.temp(h.temp), style = text(fg, 13, FontWeight.Medium, TextAlign.Center)) + } + } + } + if (size.height >= TALL.height) { + Spacer(GlanceModifier.height(12.dp)) + val today = f.daily.firstOrNull()?.date + f.daily.take(5).forEach { d -> + Row(GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + if (d.date == today) context.getString(R.string.today) else Fmt.weekdayShort(d.date), + style = text(fg, 13), modifier = GlanceModifier.width(56.dp), + ) + Image(ImageProvider(WidgetArt.icon(conditionOf(d.code), true, 96)), null, GlanceModifier.size(22.dp)) + Text(if (d.precipProb >= 10) " ${d.precipProb} %" else "", style = text(fg, 11), modifier = GlanceModifier.defaultWeight()) + Text("${u.temp(d.tMin)} ${u.temp(d.tMax)}", style = text(fg, 13, FontWeight.Medium)) + } + } + } + } + } + } + + companion object { + val WIDE = DpSize(250.dp, 110.dp) + val TALL = DpSize(250.dp, 250.dp) + } +} + +class NowWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = NowWidget() +} + +class ForecastWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = ForecastWidget() +}