build: S12 widgets (now, forecast)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 13:52:42 +02:00
co-authored by Claude Opus 5.5
parent c0bf0f6042
commit c1c76252f1
2 changed files with 276 additions and 0 deletions
@@ -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))
}
}
@@ -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<RefreshAction>()),
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()
}