build: S12 five widgets after reference, per-widget style config

- weather card (3x2 to 4x5), pill, tilted pill, bar with cookie badge, classic
- configuration screen on add and via reconfigure: location, theme, palette, opacity, live preview
- wallpaper (Material You), weather and five fixed palettes, all pairs WCAG AA
- Glance 1.2: picker previews on Android 15+, debug widget gallery
- DB v2 migration for widget theme and opacity

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 14:24:52 +02:00
co-authored by Claude Opus 5.5
parent 8eb9544189
commit 0c42d8570b
25 changed files with 1459 additions and 227 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ dependencies {
ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("androidx.work:work-runtime-ktx:2.11.0")
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
implementation("androidx.glance:glance-appwidget:1.2.0")
implementation("androidx.glance:glance-material3:1.2.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
@@ -0,0 +1,214 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "9c1a9b09f0d46a98d95c13ff8fe1ff17",
"entities": [
{
"tableName": "locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `admin` TEXT, `country_code` TEXT NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `timezone` TEXT NOT NULL, `is_current` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "admin",
"columnName": "admin",
"affinity": "TEXT"
},
{
"fieldPath": "countryCode",
"columnName": "country_code",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lat",
"columnName": "lat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lon",
"columnName": "lon",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isCurrent",
"columnName": "is_current",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_locations_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_locations_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
},
{
"tableName": "forecast_cache",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`location_id` INTEGER NOT NULL, `fetched_at` INTEGER NOT NULL, `forecast_json` TEXT NOT NULL, `air_json` TEXT, PRIMARY KEY(`location_id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "location_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetched_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forecastJson",
"columnName": "forecast_json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "airJson",
"columnName": "air_json",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"location_id"
]
},
"foreignKeys": [
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"location_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "widget_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_widget_id` INTEGER NOT NULL, `location_id` INTEGER, `style` TEXT NOT NULL, `theme` TEXT NOT NULL, `opacity` INTEGER NOT NULL, PRIMARY KEY(`app_widget_id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "app_widget_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "locationId",
"columnName": "location_id",
"affinity": "INTEGER"
},
{
"fieldPath": "style",
"columnName": "style",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "opacity",
"columnName": "opacity",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"app_widget_id"
]
},
"indices": [
{
"name": "index_widget_config_location_id",
"unique": false,
"columnNames": [
"location_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_widget_config_location_id` ON `${TABLE_NAME}` (`location_id`)"
}
],
"foreignKeys": [
{
"table": "locations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"location_id"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9c1a9b09f0d46a98d95c13ff8fe1ff17')"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".WidgetGalleryActivity" android:exported="true" android:theme="@android:style/Theme.Material.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,65 @@
package app.wetter
import android.app.Activity
import android.graphics.Color
import android.os.Bundle
import android.util.TypedValue
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.compose
import androidx.lifecycle.lifecycleScope
import app.wetter.data.Graph
import app.wetter.widget.BarWidget
import app.wetter.widget.ClassicWidget
import app.wetter.widget.PillWidget
import app.wetter.widget.TiltWidget
import app.wetter.widget.WeatherCardWidget
import kotlinx.coroutines.launch
/**
* Debug only: renders every widget at its sizes with real data, so they can be checked
* with `adb shell am start -n app.wetter/.WidgetGalleryActivity` and a screenshot.
*/
class WidgetGalleryActivity : androidx.activity.ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Graph.init(this)
val page = intent.getIntExtra("page", 0)
val column = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(px(16), px(48), px(16), px(48)) }
setContentView(ScrollView(this).apply { setBackgroundColor(Color.rgb(96, 120, 128)); addView(column) })
val pages: List<List<Pair<GlanceAppWidget, DpSize>>> = listOf(
listOf(
WeatherCardWidget() to DpSize(360.dp, 260.dp),
PillWidget() to DpSize(360.dp, 150.dp),
WeatherCardWidget() to DpSize(260.dp, 150.dp),
),
listOf(
WeatherCardWidget() to DpSize(360.dp, 520.dp),
BarWidget() to DpSize(360.dp, 90.dp),
),
listOf(
TiltWidget() to DpSize(180.dp, 180.dp),
ClassicWidget() to DpSize(360.dp, 170.dp),
ClassicWidget() to DpSize(170.dp, 170.dp),
PillWidget() to DpSize(170.dp, 64.dp),
),
)
lifecycleScope.launch {
for ((widget, size) in pages[page.coerceIn(0, pages.lastIndex)]) {
val views = widget.compose(this@WidgetGalleryActivity, size = size)
val frame = FrameLayout(this@WidgetGalleryActivity)
frame.addView(views.apply(this@WidgetGalleryActivity, frame))
column.addView(frame, LinearLayout.LayoutParams(px(size.width.value), px(size.height.value)).apply { bottomMargin = px(20) })
}
}
}
private fun px(dp: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()
private fun px(dp: Int) = px(dp.toFloat())
}
+20 -4
View File
@@ -24,13 +24,29 @@
</intent-filter>
</activity>
<receiver android:name=".widget.NowWidgetReceiver" android:exported="true" android:label="@string/widget_now_name">
<activity android:name=".widget.WidgetConfigActivity" android:exported="true" android:excludeFromRecents="true" android:taskAffinity="">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /></intent-filter>
</activity>
<receiver android:name=".widget.WeatherCardReceiver" android:exported="true" android:label="@string/widget_card_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_now" />
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_card" />
</receiver>
<receiver android:name=".widget.ForecastWidgetReceiver" android:exported="true" android:label="@string/widget_forecast_name">
<receiver android:name=".widget.PillReceiver" android:exported="true" android:label="@string/widget_pill_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_forecast" />
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_pill" />
</receiver>
<receiver android:name=".widget.TiltReceiver" android:exported="true" android:label="@string/widget_tilt_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_tilt" />
</receiver>
<receiver android:name=".widget.BarReceiver" android:exported="true" android:label="@string/widget_bar_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bar" />
</receiver>
<receiver android:name=".widget.ClassicReceiver" android:exported="true" android:label="@string/widget_classic_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_classic" />
</receiver>
</application>
</manifest>
+14 -3
View File
@@ -51,7 +51,10 @@ data class WidgetConfigEntity(
@PrimaryKey @androidx.room.ColumnInfo(name = "app_widget_id") val appWidgetId: Int,
/** null = follow the current location */
@androidx.room.ColumnInfo(name = "location_id") val locationId: Long?,
val style: String = "auto",
/** palette key, see WidgetStyle */
val style: String = "wallpaper",
val theme: String = "system",
val opacity: Int = 100,
)
@Dao
@@ -107,7 +110,7 @@ interface WeatherDao {
@Database(
entities = [LocationEntity::class, ForecastCacheEntity::class, WidgetConfigEntity::class],
version = 1,
version = 2,
exportSchema = true,
)
abstract class WeatherDb : RoomDatabase() {
@@ -115,6 +118,14 @@ abstract class WeatherDb : RoomDatabase() {
companion object {
fun create(context: Context) =
Room.databaseBuilder(context, WeatherDb::class.java, "weather.db").build()
Room.databaseBuilder(context, WeatherDb::class.java, "weather.db").addMigrations(MIGRATION_1_2).build()
private val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) {
override fun migrate(db: androidx.sqlite.db.SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE widget_config ADD COLUMN theme TEXT NOT NULL DEFAULT 'system'")
db.execSQL("ALTER TABLE widget_config ADD COLUMN opacity INTEGER NOT NULL DEFAULT 100")
db.execSQL("UPDATE widget_config SET style = 'wallpaper' WHERE style = 'auto'")
}
}
}
}
@@ -127,8 +127,9 @@ class WeatherRepository(
return config?.locationId?.let { dao.location(it) } ?: dao.currentLocation() ?: dao.locationsOnce().firstOrNull()
}
suspend fun setWidgetLocation(appWidgetId: Int, locationId: Long?) =
dao.putWidget(WidgetConfigEntity(appWidgetId, locationId))
suspend fun widgetConfig(appWidgetId: Int): WidgetConfigEntity? = dao.widget(appWidgetId)
suspend fun saveWidgetConfig(config: WidgetConfigEntity) = dao.putWidget(config)
suspend fun deleteWidget(appWidgetId: Int) = dao.deleteWidget(appWidgetId)
@@ -114,6 +114,76 @@ object WColor {
val aqiBar6 = Color(0xFF8E24AA)
val uvDotLow = Color(0xFF5CCF8A)
val uvDotOff = Color(0xFF6B5A52)
val widgetOceanLightBg = Color(0xFFCCE4F0)
val widgetOceanLightOn = Color(0xFF0F2733)
val widgetOceanLightAccent = Color(0xFF20546F)
val widgetOceanLightPanel = Color(0xFFF9FAFB)
val widgetOceanLightOnpanel = Color(0xFF192124)
val widgetOceanLightBadge = Color(0xFFA8D1E6)
val widgetOceanLightOnbadge = Color(0xFF0F2733)
val widgetOceanDarkBg = Color(0xFF194257)
val widgetOceanDarkOn = Color(0xFFDBECF5)
val widgetOceanDarkAccent = Color(0xFFB0D5E8)
val widgetOceanDarkPanel = Color(0xFF0E1315)
val widgetOceanDarkOnpanel = Color(0xFFE7ECEF)
val widgetOceanDarkBadge = Color(0xFF1F516B)
val widgetOceanDarkOnbadge = Color(0xFFDFEEF6)
val widgetForestLightBg = Color(0xFFD2E9DA)
val widgetForestLightOn = Color(0xFF162D1D)
val widgetForestLightAccent = Color(0xFF2E603F)
val widgetForestLightPanel = Color(0xFFF9FBFA)
val widgetForestLightOnpanel = Color(0xFF1B221D)
val widgetForestLightBadge = Color(0xFFB3DBC0)
val widgetForestLightOnbadge = Color(0xFF162D1D)
val widgetForestDarkBg = Color(0xFF244C32)
val widgetForestDarkOn = Color(0xFFE0F0E5)
val widgetForestDarkAccent = Color(0xFFBADEC6)
val widgetForestDarkPanel = Color(0xFF101411)
val widgetForestDarkOnpanel = Color(0xFFE8EDEA)
val widgetForestDarkBadge = Color(0xFF2D5D3D)
val widgetForestDarkOnbadge = Color(0xFFE3F2E8)
val widgetSunLightBg = Color(0xFFF7E4C5)
val widgetSunLightOn = Color(0xFF3A2808)
val widgetSunLightAccent = Color(0xFF7D5612)
val widgetSunLightPanel = Color(0xFFFBFAF9)
val widgetSunLightOnpanel = Color(0xFF272117)
val widgetSunLightBadge = Color(0xFFF1D29D)
val widgetSunLightOnbadge = Color(0xFF3A2808)
val widgetSunDarkBg = Color(0xFF62430E)
val widgetSunDarkOn = Color(0xFFF9EDD7)
val widgetSunDarkAccent = Color(0xFFF2D6A6)
val widgetSunDarkPanel = Color(0xFF17130D)
val widgetSunDarkOnpanel = Color(0xFFF0ECE5)
val widgetSunDarkBadge = Color(0xFF785311)
val widgetSunDarkOnbadge = Color(0xFFFAEFDB)
val widgetLavenderLightBg = Color(0xFFDBCFED)
val widgetLavenderLightOn = Color(0xFF1F1230)
val widgetLavenderLightAccent = Color(0xFF422768)
val widgetLavenderLightPanel = Color(0xFFFAF9FB)
val widgetLavenderLightOnpanel = Color(0xFF1E1A23)
val widgetLavenderLightBadge = Color(0xFFC3AEE0)
val widgetLavenderLightOnbadge = Color(0xFF1F1230)
val widgetLavenderDarkBg = Color(0xFF341F51)
val widgetLavenderDarkOn = Color(0xFFE6DEF2)
val widgetLavenderDarkAccent = Color(0xFFC8B5E3)
val widgetLavenderDarkPanel = Color(0xFF110F15)
val widgetLavenderDarkOnpanel = Color(0xFFEAE7EE)
val widgetLavenderDarkBadge = Color(0xFF402664)
val widgetLavenderDarkOnbadge = Color(0xFFE9E1F4)
val widgetGraphiteLightBg = Color(0xFFDBDDE1)
val widgetGraphiteLightOn = Color(0xFF1E2124)
val widgetGraphiteLightAccent = Color(0xFF42464D)
val widgetGraphiteLightPanel = Color(0xFFFAFAFA)
val widgetGraphiteLightOnpanel = Color(0xFF1E1E1F)
val widgetGraphiteLightBadge = Color(0xFFC2C6CB)
val widgetGraphiteLightOnbadge = Color(0xFF1E2124)
val widgetGraphiteDarkBg = Color(0xFF34373D)
val widgetGraphiteDarkOn = Color(0xFFE6E8EA)
val widgetGraphiteDarkAccent = Color(0xFFC8CBD0)
val widgetGraphiteDarkPanel = Color(0xFF111212)
val widgetGraphiteDarkOnpanel = Color(0xFFEAEBEB)
val widgetGraphiteDarkBadge = Color(0xFF3F444A)
val widgetGraphiteDarkOnbadge = Color(0xFFE9EAEC)
}
object WType {
@@ -1,22 +1,44 @@
package app.wetter.widget
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Build
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
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.graphics.drawscope.rotate
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.components.wavyCircle
import app.wetter.ui.scene.drawWeatherIcon
import app.wetter.ui.theme.WColor
/** Widgets cannot run shaders or animations, so the scene and icons are rendered to bitmaps. */
/** Colours taken from the wallpaper (Material You) on Android 12+, our own palette below that. */
data class WidgetPalette(
val bg: Color,
val onBg: Color,
val muted: Color,
val accent: Color,
val panel: Color,
val onPanel: Color,
val badge: Color,
val onBadge: Color,
)
/** Widgets cannot draw custom shapes, so icons and shaped backgrounds are rendered to bitmaps. */
object WidgetArt {
private fun render(w: Int, h: Int, block: DrawScope.() -> Unit): Bitmap {
val image = ImageBitmap(w, h)
@@ -26,9 +48,17 @@ object WidgetArt {
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))
/** A stadium tilted by 45°, filling a square. */
fun tiltedPill(color: Color, px: Int = 400): Bitmap = render(px, px) {
rotate(-45f) {
val w = size.width * .92f
val h = size.height * .48f
drawRoundRect(color, Offset((size.width - w) / 2, (size.height - h) / 2), Size(w, h), CornerRadius(h / 2))
}
}
/** Scalloped "cookie" badge. */
fun cookie(color: Color, px: Int = 240): Bitmap = render(px, px) {
drawPath(wavyCircle(center, size.minDimension * .44f, 10, size.minDimension * .035f), color)
}
}
@@ -0,0 +1,208 @@
package app.wetter.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.compose
import app.wetter.R
import app.wetter.data.Graph
import app.wetter.data.Settings
import app.wetter.data.WidgetConfigEntity
import app.wetter.ui.theme.WSpace
import app.wetter.ui.theme.WType
import app.wetter.ui.theme.WeatherTheme
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/** Opens when a widget is added, and again from the launcher's "customise widget". */
class WidgetConfigActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Graph.init(this)
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
// backing out leaves the widget unplaced, as the platform expects
setResult(RESULT_CANCELED, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finish(); return }
val provider = AppWidgetManager.getInstance(this).getAppWidgetInfo(appWidgetId)?.provider?.className.orEmpty()
val (widget, size) = widgetFor(provider)
setContent {
val settings by Graph.settings.settings.collectAsState(initial = Settings())
WeatherTheme(settings.theme) {
ConfigScreen(appWidgetId, widget, size) { config ->
lifecycleScope.launch {
Graph.repo.saveWidgetConfig(config)
runCatching { widget.update(this@WidgetConfigActivity, GlanceAppWidgetManager(this@WidgetConfigActivity).getGlanceIdBy(appWidgetId)) }
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
finish()
}
}
}
}
}
companion object {
fun widgetFor(providerClass: String): Pair<GlanceAppWidget, DpSize> = when {
providerClass.endsWith("PillReceiver") -> PillWidget() to DpSize(300.dp, 130.dp)
providerClass.endsWith("TiltReceiver") -> TiltWidget() to DpSize(170.dp, 170.dp)
providerClass.endsWith("BarReceiver") -> BarWidget() to DpSize(320.dp, 84.dp)
providerClass.endsWith("ClassicReceiver") -> ClassicWidget() to DpSize(320.dp, 160.dp)
else -> WeatherCardWidget() to DpSize(320.dp, 240.dp)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigScreen(appWidgetId: Int, widget: GlanceAppWidget, size: DpSize, onDone: (WidgetConfigEntity) -> Unit) {
val context = LocalContext.current
val locations by Graph.repo.locations.collectAsState(initial = emptyList())
var config by remember { mutableStateOf(WidgetConfigEntity(appWidgetId, null)) }
var loaded by remember { mutableStateOf(false) }
LaunchedEffect(appWidgetId) {
Graph.repo.widgetConfig(appWidgetId)?.let { config = it }
loaded = true
}
// live preview: render the real widget with the pending settings
var preview by remember { mutableStateOf<android.widget.RemoteViews?>(null) }
LaunchedEffect(config, loaded) {
if (!loaded) return@LaunchedEffect
WidgetPreviewOverride.config = config
preview = runCatching { widget.compose(context, size = size) }.getOrNull()
WidgetPreviewOverride.config = null
}
Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.widget_customize)) }) }) { pad ->
Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState())) {
Box(
Modifier.fillMaxWidth().height(size.height + 48.dp).padding(horizontal = WSpace.s16).clip(RoundedCornerShape(28.dp))
.background(Brush.linearGradient(listOf(Color(0xFF5F7A84), Color(0xFFB9A58F)))),
contentAlignment = Alignment.Center,
) {
preview?.let { views ->
AndroidView(
factory = { FrameLayout(it) },
update = { frame -> frame.removeAllViews(); frame.addView(views.apply(frame.context, frame)) },
modifier = Modifier.size(size),
)
}
}
Section(stringResource(R.string.widget_location))
Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = WSpace.s16), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) {
FilterChip(selected = config.locationId == null, onClick = { config = config.copy(locationId = null) }, label = { Text(stringResource(R.string.widget_follow)) })
locations.filter { !it.isCurrent }.forEach { loc ->
FilterChip(selected = config.locationId == loc.id, onClick = { config = config.copy(locationId = loc.id) }, label = { Text(loc.name) })
}
}
Section(stringResource(R.string.set_theme))
val themeLabels = listOf(R.string.theme_system, R.string.theme_light, R.string.theme_dark).map { stringResource(it) }
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16)) {
WidgetStyle.THEMES.forEachIndexed { i, key ->
SegmentedButton(config.theme == key, { config = config.copy(theme = key) }, SegmentedButtonDefaults.itemShape(i, WidgetStyle.THEMES.size)) { Text(themeLabels[i]) }
}
}
Section(stringResource(R.string.widget_colors))
val names = context.resources.getStringArray(R.array.widget_palettes)
Row(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = WSpace.s16), horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) {
WidgetStyle.PALETTES.forEachIndexed { i, key ->
val swatch = resolvePalette(context, WidgetStyle(key, config.theme, 100), null)
val selected = config.style == key
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(64.dp)) {
Box(
Modifier.size(52.dp).clip(CircleShape)
.background(Brush.linearGradient(listOf(swatch.bg, swatch.accent)))
.border(if (selected) 3.dp else 1.dp, if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape)
.clickable { config = config.copy(style = key) }
.semantics { contentDescription = names[i]; this.selected = selected },
contentAlignment = Alignment.Center,
) { if (selected) Icon(Icons.Rounded.Check, null, tint = swatch.onBg) }
Text(names[i], style = WType.labelSm, maxLines = 1)
}
}
}
Section(stringResource(R.string.widget_opacity))
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16)) {
WidgetStyle.OPACITIES.forEachIndexed { i, o ->
SegmentedButton(config.opacity == o, { config = config.copy(opacity = o) }, SegmentedButtonDefaults.itemShape(i, WidgetStyle.OPACITIES.size)) { Text("$o %") }
}
}
if (config.opacity <= 25) {
Text(stringResource(R.string.widget_opacity_hint), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = WSpace.s16, vertical = WSpace.s8))
}
Button(onClick = { onDone(config) }, enabled = loaded, modifier = Modifier.fillMaxWidth().padding(WSpace.s16).navigationBarsPadding()) {
Text(stringResource(R.string.widget_done))
}
}
}
}
@Composable
private fun Section(title: String) = Text(
title, style = WType.title, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = WSpace.s16, end = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8),
)
@@ -0,0 +1,81 @@
package app.wetter.widget
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import app.wetter.data.Forecast
import app.wetter.data.conditionOf
import app.wetter.ui.scene.SceneSpec
import app.wetter.ui.theme.WColor
/** What a person picked for one widget in the configuration screen. */
data class WidgetStyle(
val palette: String = WALLPAPER,
val theme: String = SYSTEM,
/** background opacity in percent: 100, 75, 50, 25 or 0 */
val opacity: Int = 100,
) {
companion object {
const val WALLPAPER = "wallpaper"
const val WEATHER = "weather"
const val SYSTEM = "system"
const val LIGHT = "light"
const val DARK = "dark"
val PALETTES = listOf(WALLPAPER, WEATHER, "ocean", "forest", "sun", "lavender", "graphite")
val THEMES = listOf(SYSTEM, LIGHT, DARK)
val OPACITIES = listOf(100, 75, 50, 25, 0)
}
}
private fun palette(bg: Color, on: Color, accent: Color, panel: Color, onPanel: Color, badge: Color, onBadge: Color) =
WidgetPalette(bg, on, on.copy(alpha = .72f), accent, panel, onPanel, badge, onBadge)
private val FIXED: Map<Pair<String, Boolean>, WidgetPalette> = mapOf(
Pair("ocean", false) to palette(WColor.widgetOceanLightBg, WColor.widgetOceanLightOn, WColor.widgetOceanLightAccent, WColor.widgetOceanLightPanel, WColor.widgetOceanLightOnpanel, WColor.widgetOceanLightBadge, WColor.widgetOceanLightOnbadge),
Pair("ocean", true) to palette(WColor.widgetOceanDarkBg, WColor.widgetOceanDarkOn, WColor.widgetOceanDarkAccent, WColor.widgetOceanDarkPanel, WColor.widgetOceanDarkOnpanel, WColor.widgetOceanDarkBadge, WColor.widgetOceanDarkOnbadge),
Pair("forest", false) to palette(WColor.widgetForestLightBg, WColor.widgetForestLightOn, WColor.widgetForestLightAccent, WColor.widgetForestLightPanel, WColor.widgetForestLightOnpanel, WColor.widgetForestLightBadge, WColor.widgetForestLightOnbadge),
Pair("forest", true) to palette(WColor.widgetForestDarkBg, WColor.widgetForestDarkOn, WColor.widgetForestDarkAccent, WColor.widgetForestDarkPanel, WColor.widgetForestDarkOnpanel, WColor.widgetForestDarkBadge, WColor.widgetForestDarkOnbadge),
Pair("sun", false) to palette(WColor.widgetSunLightBg, WColor.widgetSunLightOn, WColor.widgetSunLightAccent, WColor.widgetSunLightPanel, WColor.widgetSunLightOnpanel, WColor.widgetSunLightBadge, WColor.widgetSunLightOnbadge),
Pair("sun", true) to palette(WColor.widgetSunDarkBg, WColor.widgetSunDarkOn, WColor.widgetSunDarkAccent, WColor.widgetSunDarkPanel, WColor.widgetSunDarkOnpanel, WColor.widgetSunDarkBadge, WColor.widgetSunDarkOnbadge),
Pair("lavender", false) to palette(WColor.widgetLavenderLightBg, WColor.widgetLavenderLightOn, WColor.widgetLavenderLightAccent, WColor.widgetLavenderLightPanel, WColor.widgetLavenderLightOnpanel, WColor.widgetLavenderLightBadge, WColor.widgetLavenderLightOnbadge),
Pair("lavender", true) to palette(WColor.widgetLavenderDarkBg, WColor.widgetLavenderDarkOn, WColor.widgetLavenderDarkAccent, WColor.widgetLavenderDarkPanel, WColor.widgetLavenderDarkOnpanel, WColor.widgetLavenderDarkBadge, WColor.widgetLavenderDarkOnbadge),
Pair("graphite", false) to palette(WColor.widgetGraphiteLightBg, WColor.widgetGraphiteLightOn, WColor.widgetGraphiteLightAccent, WColor.widgetGraphiteLightPanel, WColor.widgetGraphiteLightOnpanel, WColor.widgetGraphiteLightBadge, WColor.widgetGraphiteLightOnbadge),
Pair("graphite", true) to palette(WColor.widgetGraphiteDarkBg, WColor.widgetGraphiteDarkOn, WColor.widgetGraphiteDarkAccent, WColor.widgetGraphiteDarkPanel, WColor.widgetGraphiteDarkOnpanel, WColor.widgetGraphiteDarkBadge, WColor.widgetGraphiteDarkOnbadge),
)
fun resolvePalette(context: Context, style: WidgetStyle, forecast: Forecast?): WidgetPalette {
val systemDark = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
val dark = when (style.theme) {
WidgetStyle.LIGHT -> false
WidgetStyle.DARK -> true
else -> systemDark
}
val base = when (style.palette) {
WidgetStyle.WALLPAPER -> wallpaper(context, dark)
WidgetStyle.WEATHER -> weather(forecast, dark)
else -> FIXED[style.palette to dark] ?: wallpaper(context, dark)
}
val a = style.opacity.coerceIn(0, 100) / 100f
return base.copy(bg = base.bg.copy(alpha = base.bg.alpha * a), panel = base.panel.copy(alpha = base.panel.alpha * a), badge = base.badge.copy(alpha = base.badge.alpha * a))
}
private fun wallpaper(context: Context, dark: Boolean): WidgetPalette {
if (Build.VERSION.SDK_INT < 31) return FIXED.getValue(("ocean" to dark))
val s = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
return palette(s.primaryContainer, s.onPrimaryContainer, s.primary, s.surfaceContainerLowest, s.onSurface, s.secondaryContainer, s.onSecondaryContainer)
}
/** Colours of the current sky: the same gradient the app shows behind the forecast. */
private fun weather(forecast: Forecast?, dark: Boolean): WidgetPalette {
val spec = forecast?.let { SceneSpec(conditionOf(it.current.code), it.current.isDay) } ?: return FIXED.getValue(("graphite" to dark))
val bg = if (dark) lerp(spec.top, Color.Black, .35f) else spec.top
val light = !dark && spec.isLight
val on = if (light) WColor.onSceneDark else WColor.onScene
val panel = if (light) WColor.sceneCardLight else WColor.sceneCard
val onPanel = if (light) WColor.onSceneDark else WColor.onScene
return palette(bg, on, on, panel, onPanel, panel, onPanel)
}
@@ -1,6 +1,7 @@
package app.wetter.widget
import android.content.Context
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize
@@ -12,18 +13,16 @@ 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.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
@@ -42,7 +41,7 @@ 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 androidx.glance.unit.ColorProvider
import app.wetter.MainActivity
import app.wetter.R
import app.wetter.data.Forecast
@@ -52,102 +51,223 @@ 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
import kotlin.reflect.KClass
suspend fun updateAllWidgets(context: Context) {
NowWidget().updateAll(context)
ForecastWidget().updateAll(context)
WeatherCardWidget().updateAll(context)
PillWidget().updateAll(context)
TiltWidget().updateAll(context)
BarWidget().updateAll(context)
ClassicWidget().updateAll(context)
}
private class WidgetData(val location: LocationEntity?, val forecast: Forecast?, val units: Units)
/** Picker previews (Android 15+). The system rate-limits this, so failures are ignored. */
suspend fun publishWidgetPreviews(context: Context) {
if (Build.VERSION.SDK_INT < 35) return
val manager = GlanceAppWidgetManager(context)
listOf<KClass<out GlanceAppWidgetReceiver>>(
WeatherCardReceiver::class, PillReceiver::class, TiltReceiver::class, BarReceiver::class, ClassicReceiver::class,
).forEach { runCatching { manager.setWidgetPreviews(it) } }
}
private suspend fun load(context: Context, id: GlanceId): WidgetData {
class WidgetData(val location: LocationEntity?, val forecast: Forecast?, val units: Units, val palette: WidgetPalette) {
val name: String get() = location?.name.orEmpty()
}
/** Set by the configuration screen while it renders its live preview. */
object WidgetPreviewOverride {
@Volatile var config: app.wetter.data.WidgetConfigEntity? = null
}
suspend fun loadWidgetData(context: Context, id: GlanceId?): WidgetData {
Graph.init(context)
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
val location = Graph.repo.widgetLocation(appWidgetId)
// ids that are not real widgets (previews, the debug gallery) fall back to the current location
val appWidgetId = id?.let { runCatching { GlanceAppWidgetManager(context).getAppWidgetId(it) }.getOrNull() } ?: -1
val config = WidgetPreviewOverride.config ?: Graph.repo.widgetConfig(appWidgetId)
val location = config?.locationId?.let { Graph.repo.location(it) } ?: 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))
val style = config?.let { WidgetStyle(it.style, it.theme, it.opacity) } ?: WidgetStyle()
return WidgetData(location, forecast, Units(settings), resolvePalette(context, style, forecast))
}
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))
/** Base: loads data once per update, renders [Content], and offers the same content as picker preview. */
abstract class WeatherWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = load(context, id)
provideContent { Content(data) }
val data = loadWidgetData(context, id)
provideContent { Root(data) }
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
val data = loadWidgetData(context, null)
provideContent { Root(data) }
}
@Composable
private fun Content(data: WidgetData) {
fun Root(data: WidgetData) {
val context = LocalContext.current
val f = data.forecast
if (f == null) {
Box(
GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(data.palette.bg)
.clickable(actionStartActivity(MainActivity.intent(context, null))),
contentAlignment = Alignment.Center,
) { Text(context.getString(R.string.widget_pick), style = text(data.palette.onBg, 14)) }
return
}
Content(data, f)
}
@Composable
abstract fun Content(data: WidgetData, f: Forecast)
}
private fun text(color: Color, size: Int, weight: FontWeight = FontWeight.Normal, align: TextAlign = TextAlign.Start) =
TextStyle(color = ColorProvider(color), fontSize = size.sp, fontWeight = weight, textAlign = align)
@Composable
private fun open(data: WidgetData) = actionStartActivity(MainActivity.intent(LocalContext.current, data.location?.id))
@Composable
private fun Icon(f: Forecast, size: Int, code: Int = f.current.code, isDay: Boolean = f.current.isDay) =
Image(ImageProvider(WidgetArt.icon(conditionOf(code), isDay)), null, GlanceModifier.size(size.dp))
@Composable
private fun conditionText(f: Forecast): String =
LocalContext.current.getString(conditionLabel(conditionOf(f.current.code), f.current.isDay))
@Composable
private fun Attribution(p: WidgetPalette) = Text(
LocalContext.current.getString(R.string.widget_source), style = text(p.muted, 10, align = TextAlign.Center),
modifier = GlanceModifier.fillMaxWidth(),
)
/** One hour column: temperature, icon, time. */
@Composable
private fun HourCell(f: Forecast, index: Int, data: WidgetData, color: Color, modifier: GlanceModifier) {
val h = f.upcomingHours(index + 1).getOrNull(index) ?: return
val ctx = LocalContext.current
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(data.units.temp(h.temp), style = text(color, 13, align = TextAlign.Center))
Spacer(GlanceModifier.height(4.dp))
Image(ImageProvider(WidgetArt.icon(conditionOf(h.code), h.isDay, 96)), null, GlanceModifier.size(24.dp))
Spacer(GlanceModifier.height(4.dp))
Text(if (index == 0) ctx.getString(R.string.now) else Fmt.hourLabel(h.time), style = text(color, 12, align = TextAlign.Center))
}
}
// ---------------------------------------------------------------------------------------------
// 1. Weather card: grows from a compact 3×2 to a full 4×5 with hourly panel and daily list
// ---------------------------------------------------------------------------------------------
class WeatherCardWidget : WeatherWidget() {
override val sizeMode = SizeMode.Responsive(setOf(COMPACT, MEDIUM, LARGE, FULL))
@Composable
override fun Content(data: WidgetData, f: Forecast) {
val cond = conditionText(f)
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))
val p = data.palette
val u = data.units
val ctx = LocalContext.current
val today = f.today
Column(
GlanceModifier.fillMaxSize().cornerRadius(28.dp).background(p.bg).clickable(open(data)).padding(16.dp)
.semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
) {
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(GlanceModifier.defaultWeight()) {
if (size.height >= LARGE.height && data.name.isNotEmpty()) {
Text(data.name, style = text(p.accent, 13, FontWeight.Medium), maxLines = 1)
Spacer(GlanceModifier.height(6.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(f, 30)
Spacer(GlanceModifier.width(8.dp))
Text(conditionText(f), style = text(p.onBg, 16, FontWeight.Medium), maxLines = 1)
}
Spacer(GlanceModifier.height(4.dp))
Text(ctx.getString(R.string.feels_like, u.temp(f.current.feelsLike)), style = text(p.onBg, 14), maxLines = 1)
if (today != null) Text("${u.temp(today.tMax)} · ${u.temp(today.tMin)}", style = text(p.muted, 14))
}
Text(u.temp(f.current.temp), style = text(p.onBg, if (size.width >= MEDIUM.width) 56 else 44))
}
if (size.height < MEDIUM.height) {
// compact: two hours under the temperature
Spacer(GlanceModifier.defaultWeight())
Row(GlanceModifier.fillMaxWidth()) {
Spacer(GlanceModifier.defaultWeight())
HourCell(f, 0, data, p.onBg, GlanceModifier.width(48.dp))
HourCell(f, 1, data, p.onBg, GlanceModifier.width(48.dp))
}
return@Column
}
Spacer(GlanceModifier.height(12.dp))
Row(GlanceModifier.fillMaxWidth().cornerRadius(20.dp).background(p.panel).padding(vertical = 10.dp)) {
for (i in 0 until 6) HourCell(f, i, data, p.onPanel, GlanceModifier.defaultWeight())
}
if (size.height >= LARGE.height) {
Spacer(GlanceModifier.height(8.dp))
val days = f.daily.drop(1).take(if (size.height >= FULL.height) 5 else 1)
Column(GlanceModifier.fillMaxWidth().cornerRadius(20.dp).background(p.panel)) {
days.forEachIndexed { i, d ->
Row(GlanceModifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
d.date.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.getDefault()),
style = text(p.onPanel, 14), modifier = GlanceModifier.defaultWeight(),
)
Text("${u.temp(d.tMax)} ", style = text(p.onPanel, 14, FontWeight.Medium))
Text(u.temp(d.tMin), style = text(p.onPanel.copy(alpha = .7f), 14))
Spacer(GlanceModifier.width(8.dp))
Image(ImageProvider(WidgetArt.icon(conditionOf(d.code), true, 96)), null, GlanceModifier.size(22.dp))
}
if (i < days.lastIndex) Spacer(GlanceModifier.fillMaxWidth().height(1.dp).background(p.bg))
}
}
}
Spacer(GlanceModifier.defaultWeight())
Attribution(p)
}
}
companion object {
val COMPACT = DpSize(180.dp, 110.dp)
val MEDIUM = DpSize(250.dp, 180.dp)
val LARGE = DpSize(250.dp, 310.dp)
val FULL = DpSize(250.dp, 430.dp)
}
}
// ---------------------------------------------------------------------------------------------
// 2. Pill: stadium with a big icon and the temperature
// ---------------------------------------------------------------------------------------------
class PillWidget : WeatherWidget() {
override val sizeMode = SizeMode.Responsive(setOf(SMALL, WIDE))
@Composable
override fun Content(data: WidgetData, f: Forecast) {
val cond = conditionText(f)
val size = LocalSize.current
val p = data.palette
val u = data.units
val big = size.height >= WIDE.height
Row(
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(open(data)).padding(horizontal = if (big) 24.dp else 16.dp)
.semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(f, if (big) 84 else 36)
Spacer(GlanceModifier.width(if (big) 16.dp else 8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(u.temp(f.current.temp), style = text(p.accent, if (big) 56 else 30))
if (big) f.today?.let {
Row {
Text(u.temp(it.tMax) + " ", style = text(p.accent, 18))
Text(u.temp(it.tMin), style = text(p.muted, 18))
}
Text(condText, style = text(fg, 13), maxLines = 1)
f.today?.let { Text("↑ ${u.temp(it.tMax)} ↓ ${u.temp(it.tMin)}", style = text(fg, 12)) }
}
}
}
@@ -155,88 +275,133 @@ class NowWidget : GlanceAppWidget() {
companion object {
val SMALL = DpSize(110.dp, 48.dp)
val MEDIUM = DpSize(110.dp, 110.dp)
val WIDE = DpSize(200.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))
// ---------------------------------------------------------------------------------------------
// 3. Tilted pill: a 45° stadium, temperature top right, icon bottom left
// ---------------------------------------------------------------------------------------------
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = load(context, id)
provideContent { Content(data) }
}
class TiltWidget : WeatherWidget() {
override val sizeMode = SizeMode.Exact
@Composable
private fun Content(data: WidgetData) {
val context = LocalContext.current
override fun Content(data: WidgetData, f: Forecast) {
val cond = conditionText(f)
val p = data.palette
val u = data.units
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))
val side = minOf(size.width.value, size.height.value)
Box(
GlanceModifier.fillMaxSize().clickable(open(data))
.semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
contentAlignment = Alignment.Center,
) {
Box(GlanceModifier.size(side.dp)) {
Image(ImageProvider(WidgetArt.tiltedPill(p.bg)), null, GlanceModifier.fillMaxSize(), contentScale = ContentScale.FillBounds)
Box(GlanceModifier.fillMaxSize().padding(top = (side * .22f).dp, end = (side * .2f).dp), contentAlignment = Alignment.TopEnd) {
Text(u.temp(f.current.temp), style = text(p.onBg, (side * .22f).toInt()))
}
Box(GlanceModifier.fillMaxSize().padding(bottom = (side * .2f).dp, start = (side * .2f).dp), contentAlignment = Alignment.BottomStart) {
Icon(f, (side * .28f).toInt())
}
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)),
)
}
}
}
// ---------------------------------------------------------------------------------------------
// 4. Bar: one line "today", condition, and a cookie badge with the current temperature
// ---------------------------------------------------------------------------------------------
class BarWidget : WeatherWidget() {
override val sizeMode = SizeMode.Exact
@Composable
override fun Content(data: WidgetData, f: Forecast) {
val cond = conditionText(f)
val p = data.palette
val u = data.units
val ctx = LocalContext.current
val size = LocalSize.current
val badge = minOf(size.height.value - 8f, 88f)
Row(
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.panel).clickable(open(data)).padding(start = 22.dp, end = 6.dp)
.semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
verticalAlignment = Alignment.CenterVertically,
) {
Column(GlanceModifier.defaultWeight()) {
f.today?.let { Text("${ctx.getString(R.string.today)} ${u.temp(it.tMax)} / ${u.temp(it.tMin)}", style = text(p.onPanel, 15), maxLines = 1) }
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(f, 22)
Spacer(GlanceModifier.width(6.dp))
Text(conditionText(f), style = text(p.onPanel, 14), maxLines = 1)
}
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))
}
Box(GlanceModifier.size(badge.dp), contentAlignment = Alignment.Center) {
Image(ImageProvider(WidgetArt.cookie(p.badge)), null, GlanceModifier.fillMaxSize())
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(f, (badge * .38f).toInt())
Text(u.temp(f.current.temp), style = text(p.onBadge, 13, FontWeight.Medium))
}
}
}
}
}
// ---------------------------------------------------------------------------------------------
// 5. Classic: rounded square, place and condition in the accent colour, hours on the right
// ---------------------------------------------------------------------------------------------
class ClassicWidget : WeatherWidget() {
override val sizeMode = SizeMode.Responsive(setOf(SQUARE, WIDE))
@Composable
override fun Content(data: WidgetData, f: Forecast) {
val cond = conditionText(f)
val p = data.palette
val u = data.units
val wide = LocalSize.current.width >= WIDE.width
Column(
GlanceModifier.fillMaxSize().cornerRadius(24.dp).background(p.bg).clickable(open(data)).padding(16.dp)
.semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
) {
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Icon(f, 30)
Spacer(GlanceModifier.defaultWeight())
Column(horizontalAlignment = Alignment.End) {
Text(data.name, style = text(p.accent, 13), maxLines = 1)
Text(conditionText(f), style = text(p.accent, 17, FontWeight.Medium), maxLines = 1)
}
}
Spacer(GlanceModifier.defaultWeight())
Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
Column {
Text(u.temp(f.current.temp), style = text(p.accent, 46, FontWeight.Medium))
f.today?.let {
Row {
Text(u.temp(it.tMax) + " ", style = text(p.accent, 14))
Text(u.temp(it.tMin), style = text(p.muted, 14))
}
}
}
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))
}
}
if (wide) {
Spacer(GlanceModifier.defaultWeight())
for (i in 1..4) HourCell(f, i, data, p.onBg, GlanceModifier.width(44.dp))
}
}
}
}
companion object {
val SQUARE = DpSize(140.dp, 140.dp)
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()
}
class WeatherCardReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = WeatherCardWidget() }
class PillReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = PillWidget() }
class TiltReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = TiltWidget() }
class BarReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = BarWidget() }
class ClassicReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClassicWidget() }
@@ -11,6 +11,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import app.wetter.data.Graph
import app.wetter.widget.publishWidgetPreviews
import app.wetter.widget.updateAllWidgets
import java.util.concurrent.TimeUnit
@@ -21,6 +22,7 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorke
val force = inputData.getBoolean(KEY_FORCE, false)
runCatching { Graph.repo.refreshAll(force) }
updateAllWidgets(applicationContext)
publishWidgetPreviews(applicationContext)
return Result.success()
}
+20 -4
View File
@@ -113,10 +113,26 @@
<string name="set_about">Datenquellen</string>
<string name="attribution">Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende</string>
<string name="widget_now_name">Wetter jetzt</string>
<string name="widget_now_desc">Temperatur und Wetterlage auf einen Blick</string>
<string name="widget_forecast_name">Vorhersage</string>
<string name="widget_forecast_desc">Jetzt, die nächsten Stunden und Tage</string>
<string name="widget_card_name">Wetterkarte</string>
<string name="widget_card_desc">Jetzt, die nächsten Stunden und Tage. Wächst mit der Größe.</string>
<string name="widget_pill_name">Wetter-Pille</string>
<string name="widget_pill_desc">Icon und Temperatur in einer Pille</string>
<string name="widget_tilt_name">Schräge Pille</string>
<string name="widget_tilt_desc">Temperatur in einer schrägen Form</string>
<string name="widget_bar_name">Wetterleiste</string>
<string name="widget_bar_desc">Heute auf einen Blick, eine Zeile</string>
<string name="widget_classic_name">Klassisch</string>
<string name="widget_classic_desc">Ort, Wetterlage und die nächsten Stunden</string>
<string name="widget_customize">Widget anpassen</string>
<string name="widget_location">Ort</string>
<string name="widget_colors">Farben</string>
<string name="widget_opacity">Deckkraft des Hintergrunds</string>
<string name="widget_opacity_hint">Bei wenig Deckkraft hängt die Lesbarkeit vom Hintergrundbild ab.</string>
<string name="widget_done">Fertig</string>
<string-array name="widget_palettes">
<item>Wallpaper</item><item>Wetterlage</item><item>Ozean</item><item>Wald</item><item>Sonne</item><item>Lavendel</item><item>Graphit</item>
</string-array>
<string name="widget_source">Quelle: Open-Meteo</string>
<string name="widget_pick">Ort wählen</string>
<string name="widget_follow">Mein Standort (folgt dir)</string>
<string name="widget_loading">…</string>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_bar_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="60dp"
android:targetCellWidth="4" android:targetCellHeight="1"
android:minResizeWidth="200dp" android:minResizeHeight="48dp"
android:maxResizeWidth="600dp" android:maxResizeHeight="110dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_card_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="180dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="180dp" android:minResizeHeight="110dp"
android:maxResizeWidth="600dp" android:maxResizeHeight="600dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />
@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_forecast_desc"
android:description="@string/widget_classic_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="110dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="250dp" android:minResizeHeight="110dp"
android:minResizeWidth="140dp" android:minResizeHeight="110dp"
android:maxResizeWidth="400dp" android:maxResizeHeight="250dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_now_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp" android:minHeight="48dp"
android:targetCellWidth="2" android:targetCellHeight="2"
android:minResizeWidth="110dp" android:minResizeHeight="48dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen|keyguard" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_pill_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="200dp" android:minHeight="110dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="110dp" android:minResizeHeight="48dp"
android:maxResizeWidth="400dp" android:maxResizeHeight="200dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_tilt_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp" android:minHeight="110dp"
android:targetCellWidth="2" android:targetCellHeight="2"
android:minResizeWidth="80dp" android:minResizeHeight="80dp"
android:maxResizeWidth="300dp" android:maxResizeHeight="300dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />