build: S12 widgets (now, forecast)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user