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
+3 -2
View File
@@ -19,8 +19,9 @@ A weather app with the look and feel of Pixel Weather: an animated sky that chan
- **No Google Play Services.** Location uses the platform's own providers (including the AOSP fused provider), so it works on GrapheneOS without sandboxed Play. - **No Google Play Services.** Location uses the platform's own providers (including the AOSP fused provider), so it works on GrapheneOS without sandboxed Play.
- **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service. - **No account, no tracking, no analytics.** Everything stays on the device. Coordinates are rounded to two decimals (about 1 km) before they reach the weather service.
- **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location. - **Open data sources only:** [Open-Meteo](https://open-meteo.com) for forecasts, 15-minute precipitation, air quality, pollen and place search (CC BY 4.0), and [Nominatim / OpenStreetMap](https://nominatim.org) for naming your current location.
- **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode, and two home-screen widgets ("Now" 2×1/2×2 and "Forecast" 4×2/4×3). - **Features today:** current location and saved cities (swipe between them), search with a full preview before saving, precipitation-in-the-next-hours card, a rule-based daily summary, day detail view, units and theme settings, light and dark mode.
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), more widget sizes and a widget configuration screen, precipitation radar map, card reordering, notifications. - **Five home-screen widgets:** a weather card that grows from 3×2 to 4×5 (hourly panel, then a daily list), a pill, a tilted pill, a one-line bar with a cookie badge, and a classic card. Each one can be customised when added or later: location, system/light/dark, colours (wallpaper Material You, current weather, or five fixed palettes, all WCAG AA checked) and background opacity from 100 % down to fully transparent.
- **Coming next:** severe weather alerts (DWD / MeteoAlarm), precipitation radar map, card reordering, notifications.
Requires Android 10 (API 29) or newer. Requires Android 10 (API 29) or newer.
+2 -1
View File
@@ -12,7 +12,8 @@ Stack as in architecture.md. App in `wetter/`. Screenshots are rendered on the J
| S15 Preview | 2026-10-17 | done | – | – | | S15 Preview | 2026-10-17 | done | – | – |
| S04 Day detail | 2026-10-17 | done | – | tiles need day-specific values (noon), not today's | | S04 Day detail | 2026-10-17 | done | – | tiles need day-specific values (noon), not today's |
| S08/S09 Settings | 2026-10-17 | done | notification settings (no notifications yet) | – | | S08/S09 Settings | 2026-10-17 | done | notification settings (no notifications yet) | – |
| S12 Widgets | 2026-10-17 | partial | config activity (pick a location), 4×4 and 4×1 types, dynamic-colour style; **no screenshot** (Glance does not render in Robolectric), only verified to compile | widgets cannot draw shaders → the scene is pre-rendered to a bitmap | | S12 Widgets | 2026-10-17 | done | – | Glance 1.1 could not render outside the launcher; upgraded to 1.2 for compose() and picker previews, checked on the phone via a debug gallery |
| S12 Widgets (first pass) | 2026-10-17 | superseded | config activity (pick a location), 4×4 and 4×1 types, dynamic-colour style; **no screenshot** (Glance does not render in Robolectric), only verified to compile | widgets cannot draw shaders → the scene is pre-rendered to a bitmap |
| S05, S06, S10, S11, S13 | – | not started | card detail, radar map, reordering, alert detail, notifications | – | | S05, S06, S10, S11, S13 | – | not started | card detail, radar map, reordering, alert detail, notifications | – |
Not yet verified on a real device: location without Play Services, network on device, widgets on the launcher, WorkManager timing. Not yet verified on a real device: location without Play Services, network on device, widgets on the launcher, WorkManager timing.
+70
View File
@@ -114,6 +114,76 @@ object WColor {
val aqiBar6 = Color(0xFF8E24AA) val aqiBar6 = Color(0xFF8E24AA)
val uvDotLow = Color(0xFF5CCF8A) val uvDotLow = Color(0xFF5CCF8A)
val uvDotOff = Color(0xFF6B5A52) 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 { object WType {
+231 -1
View File
@@ -106,7 +106,77 @@
"aqi-bar-5": "#e53935", "aqi-bar-5": "#e53935",
"aqi-bar-6": "#8e24aa", "aqi-bar-6": "#8e24aa",
"uv-dot-low": "#5ccf8a", "uv-dot-low": "#5ccf8a",
"uv-dot-off": "#6b5a52" "uv-dot-off": "#6b5a52",
"widget-ocean-light-bg": "#cce4f0",
"widget-ocean-light-on": "#0f2733",
"widget-ocean-light-accent": "#20546f",
"widget-ocean-light-panel": "#f9fafb",
"widget-ocean-light-onpanel": "#192124",
"widget-ocean-light-badge": "#a8d1e6",
"widget-ocean-light-onbadge": "#0f2733",
"widget-ocean-dark-bg": "#194257",
"widget-ocean-dark-on": "#dbecf5",
"widget-ocean-dark-accent": "#b0d5e8",
"widget-ocean-dark-panel": "#0e1315",
"widget-ocean-dark-onpanel": "#e7ecef",
"widget-ocean-dark-badge": "#1f516b",
"widget-ocean-dark-onbadge": "#dfeef6",
"widget-forest-light-bg": "#d2e9da",
"widget-forest-light-on": "#162d1d",
"widget-forest-light-accent": "#2e603f",
"widget-forest-light-panel": "#f9fbfa",
"widget-forest-light-onpanel": "#1b221d",
"widget-forest-light-badge": "#b3dbc0",
"widget-forest-light-onbadge": "#162d1d",
"widget-forest-dark-bg": "#244c32",
"widget-forest-dark-on": "#e0f0e5",
"widget-forest-dark-accent": "#badec6",
"widget-forest-dark-panel": "#101411",
"widget-forest-dark-onpanel": "#e8edea",
"widget-forest-dark-badge": "#2d5d3d",
"widget-forest-dark-onbadge": "#e3f2e8",
"widget-sun-light-bg": "#f7e4c5",
"widget-sun-light-on": "#3a2808",
"widget-sun-light-accent": "#7d5612",
"widget-sun-light-panel": "#fbfaf9",
"widget-sun-light-onpanel": "#272117",
"widget-sun-light-badge": "#f1d29d",
"widget-sun-light-onbadge": "#3a2808",
"widget-sun-dark-bg": "#62430e",
"widget-sun-dark-on": "#f9edd7",
"widget-sun-dark-accent": "#f2d6a6",
"widget-sun-dark-panel": "#17130d",
"widget-sun-dark-onpanel": "#f0ece5",
"widget-sun-dark-badge": "#785311",
"widget-sun-dark-onbadge": "#faefdb",
"widget-lavender-light-bg": "#dbcfed",
"widget-lavender-light-on": "#1f1230",
"widget-lavender-light-accent": "#422768",
"widget-lavender-light-panel": "#faf9fb",
"widget-lavender-light-onpanel": "#1e1a23",
"widget-lavender-light-badge": "#c3aee0",
"widget-lavender-light-onbadge": "#1f1230",
"widget-lavender-dark-bg": "#341f51",
"widget-lavender-dark-on": "#e6def2",
"widget-lavender-dark-accent": "#c8b5e3",
"widget-lavender-dark-panel": "#110f15",
"widget-lavender-dark-onpanel": "#eae7ee",
"widget-lavender-dark-badge": "#402664",
"widget-lavender-dark-onbadge": "#e9e1f4",
"widget-graphite-light-bg": "#dbdde1",
"widget-graphite-light-on": "#1e2124",
"widget-graphite-light-accent": "#42464d",
"widget-graphite-light-panel": "#fafafa",
"widget-graphite-light-onpanel": "#1e1e1f",
"widget-graphite-light-badge": "#c2c6cb",
"widget-graphite-light-onbadge": "#1e2124",
"widget-graphite-dark-bg": "#34373d",
"widget-graphite-dark-on": "#e6e8ea",
"widget-graphite-dark-accent": "#c8cbd0",
"widget-graphite-dark-panel": "#111212",
"widget-graphite-dark-onpanel": "#eaebeb",
"widget-graphite-dark-badge": "#3f444a",
"widget-graphite-dark-onbadge": "#e9eaec"
}, },
"pairs": [ "pairs": [
[ [
@@ -396,6 +466,166 @@
"nowcast-dot", "nowcast-dot",
"card", "card",
"ui" "ui"
],
[
"widget-ocean-light-on",
"widget-ocean-light-bg"
],
[
"widget-ocean-light-accent",
"widget-ocean-light-bg"
],
[
"widget-ocean-light-onpanel",
"widget-ocean-light-panel"
],
[
"widget-ocean-light-onbadge",
"widget-ocean-light-badge"
],
[
"widget-ocean-dark-on",
"widget-ocean-dark-bg"
],
[
"widget-ocean-dark-accent",
"widget-ocean-dark-bg"
],
[
"widget-ocean-dark-onpanel",
"widget-ocean-dark-panel"
],
[
"widget-ocean-dark-onbadge",
"widget-ocean-dark-badge"
],
[
"widget-forest-light-on",
"widget-forest-light-bg"
],
[
"widget-forest-light-accent",
"widget-forest-light-bg"
],
[
"widget-forest-light-onpanel",
"widget-forest-light-panel"
],
[
"widget-forest-light-onbadge",
"widget-forest-light-badge"
],
[
"widget-forest-dark-on",
"widget-forest-dark-bg"
],
[
"widget-forest-dark-accent",
"widget-forest-dark-bg"
],
[
"widget-forest-dark-onpanel",
"widget-forest-dark-panel"
],
[
"widget-forest-dark-onbadge",
"widget-forest-dark-badge"
],
[
"widget-sun-light-on",
"widget-sun-light-bg"
],
[
"widget-sun-light-accent",
"widget-sun-light-bg"
],
[
"widget-sun-light-onpanel",
"widget-sun-light-panel"
],
[
"widget-sun-light-onbadge",
"widget-sun-light-badge"
],
[
"widget-sun-dark-on",
"widget-sun-dark-bg"
],
[
"widget-sun-dark-accent",
"widget-sun-dark-bg"
],
[
"widget-sun-dark-onpanel",
"widget-sun-dark-panel"
],
[
"widget-sun-dark-onbadge",
"widget-sun-dark-badge"
],
[
"widget-lavender-light-on",
"widget-lavender-light-bg"
],
[
"widget-lavender-light-accent",
"widget-lavender-light-bg"
],
[
"widget-lavender-light-onpanel",
"widget-lavender-light-panel"
],
[
"widget-lavender-light-onbadge",
"widget-lavender-light-badge"
],
[
"widget-lavender-dark-on",
"widget-lavender-dark-bg"
],
[
"widget-lavender-dark-accent",
"widget-lavender-dark-bg"
],
[
"widget-lavender-dark-onpanel",
"widget-lavender-dark-panel"
],
[
"widget-lavender-dark-onbadge",
"widget-lavender-dark-badge"
],
[
"widget-graphite-light-on",
"widget-graphite-light-bg"
],
[
"widget-graphite-light-accent",
"widget-graphite-light-bg"
],
[
"widget-graphite-light-onpanel",
"widget-graphite-light-panel"
],
[
"widget-graphite-light-onbadge",
"widget-graphite-light-badge"
],
[
"widget-graphite-dark-on",
"widget-graphite-dark-bg"
],
[
"widget-graphite-dark-accent",
"widget-graphite-dark-bg"
],
[
"widget-graphite-dark-onpanel",
"widget-graphite-dark-panel"
],
[
"widget-graphite-dark-onbadge",
"widget-graphite-dark-badge"
] ]
], ],
"font": { "font": {
+3 -3
View File
@@ -29,7 +29,7 @@ Severe weather alerts banner on top,alerts,must,yes,no,DWD / MeteoAlarm feeds
Severe weather notification,notifications,should,yes,no,background worker Severe weather notification,notifications,should,yes,no,background worker
Evening tomorrow-forecast notification,notifications,could,yes,no, Evening tomorrow-forecast notification,notifications,could,yes,no,
Precipitation-starting notification per city,notifications,could,yes,no,needs minute nowcast Precipitation-starting notification per city,notifications,could,yes,no,needs minute nowcast
Homescreen widget,widgets,must,yes,partial,"2 of 5 widget types (Now 2x1/2x2, Forecast 4x2/4x3); no config activity yet" Homescreen widget,widgets,must,yes,yes,5 widget types with config screen
AI weather brief summary,ai,could,yes,partial,"template-based text or local LLM; no Gemini; rule-based brief, no LLM" AI weather brief summary,ai,could,yes,partial,"template-based text or local LLM; no Gemini; rule-based brief, no LLM"
Weather vibrations on animations,polish,could,yes,no, Weather vibrations on animations,polish,could,yes,no,
Tablet two-column layout,layout,could,yes,partial,content capped at 720dp and centred; no true two-pane Tablet two-column layout,layout,could,yes,partial,content capped at 720dp and centred; no true two-pane
@@ -37,8 +37,8 @@ Sync saved locations across devices via Google account,sync,could,yes,skip,no Go
Personal insights from Calendar and Gmail,ai,could,yes,skip,needs Google data access; against project goal Personal insights from Calendar and Gmail,ai,could,yes,skip,needs Google data access; against project goal
Google nowcasting / MetNet models,data,could,yes,skip,proprietary model; use public forecast data Google nowcasting / MetNet models,data,could,yes,skip,proprietary model; use public forecast data
At a Glance integration,widgets,could,yes,skip,Pixel launcher system feature At a Glance integration,widgets,could,yes,skip,Pixel launcher system feature
Widget sizes small medium large with hourly/daily,widgets,must,yes,partial,Glance; resizable; small/medium/wide/tall done; 4x4 full and 4x1 glance missing Widget sizes small medium large with hourly/daily,widgets,must,yes,yes,Glance
Widget dynamic Material You colours and condition background,widgets,should,yes,partial,condition background only; dynamic colour style missing Widget dynamic Material You colours and condition background,widgets,should,yes,yes,condition background only
Widget tap opens city / refresh on schedule,widgets,must,yes,yes, Widget tap opens city / refresh on schedule,widgets,must,yes,yes,
Nowcast card (precip starting with 15-min dot chart),cards,should,yes,yes,Open-Meteo minutely_15 Nowcast card (precip starting with 15-min dot chart),cards,should,yes,yes,Open-Meteo minutely_15
Location preview with Save,locations,should,yes,yes,S15 Location preview with Save,locations,should,yes,yes,S15
1 feature area priority original clone notes
29 Severe weather notification notifications should yes no background worker
30 Evening tomorrow-forecast notification notifications could yes no
31 Precipitation-starting notification per city notifications could yes no needs minute nowcast
32 Homescreen widget widgets must yes partial yes 2 of 5 widget types (Now 2x1/2x2, Forecast 4x2/4x3); no config activity yet 5 widget types with config screen
33 AI weather brief summary ai could yes partial template-based text or local LLM; no Gemini; rule-based brief, no LLM
34 Weather vibrations on animations polish could yes no
35 Tablet two-column layout layout could yes partial content capped at 720dp and centred; no true two-pane
37 Personal insights from Calendar and Gmail ai could yes skip needs Google data access; against project goal
38 Google nowcasting / MetNet models data could yes skip proprietary model; use public forecast data
39 At a Glance integration widgets could yes skip Pixel launcher system feature
40 Widget sizes small medium large with hourly/daily widgets must yes partial yes Glance; resizable; small/medium/wide/tall done; 4x4 full and 4x1 glance missing Glance
41 Widget dynamic Material You colours and condition background widgets should yes partial yes condition background only; dynamic colour style missing condition background only
42 Widget tap opens city / refresh on schedule widgets must yes yes
43 Nowcast card (precip starting with 15-min dot chart) cards should yes yes Open-Meteo minutely_15
44 Location preview with Save locations should yes yes S15
+2 -2
View File
@@ -61,8 +61,8 @@ dependencies {
ksp("androidx.room:room-compiler:2.7.0") ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("androidx.work:work-runtime-ktx:2.11.0") implementation("androidx.work:work-runtime-ktx:2.11.0")
implementation("androidx.glance:glance-appwidget:1.1.1") implementation("androidx.glance:glance-appwidget:1.2.0")
implementation("androidx.glance:glance-material3:1.1.1") implementation("androidx.glance:glance-material3:1.2.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.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> </intent-filter>
</activity> </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> <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>
<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> <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> </receiver>
</application> </application>
</manifest> </manifest>
+14 -3
View File
@@ -51,7 +51,10 @@ data class WidgetConfigEntity(
@PrimaryKey @androidx.room.ColumnInfo(name = "app_widget_id") val appWidgetId: Int, @PrimaryKey @androidx.room.ColumnInfo(name = "app_widget_id") val appWidgetId: Int,
/** null = follow the current location */ /** null = follow the current location */
@androidx.room.ColumnInfo(name = "location_id") val locationId: Long?, @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 @Dao
@@ -107,7 +110,7 @@ interface WeatherDao {
@Database( @Database(
entities = [LocationEntity::class, ForecastCacheEntity::class, WidgetConfigEntity::class], entities = [LocationEntity::class, ForecastCacheEntity::class, WidgetConfigEntity::class],
version = 1, version = 2,
exportSchema = true, exportSchema = true,
) )
abstract class WeatherDb : RoomDatabase() { abstract class WeatherDb : RoomDatabase() {
@@ -115,6 +118,14 @@ abstract class WeatherDb : RoomDatabase() {
companion object { companion object {
fun create(context: Context) = 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() return config?.locationId?.let { dao.location(it) } ?: dao.currentLocation() ?: dao.locationsOnce().firstOrNull()
} }
suspend fun setWidgetLocation(appWidgetId: Int, locationId: Long?) = suspend fun widgetConfig(appWidgetId: Int): WidgetConfigEntity? = dao.widget(appWidgetId)
dao.putWidget(WidgetConfigEntity(appWidgetId, locationId))
suspend fun saveWidgetConfig(config: WidgetConfigEntity) = dao.putWidget(config)
suspend fun deleteWidget(appWidgetId: Int) = dao.deleteWidget(appWidgetId) suspend fun deleteWidget(appWidgetId: Int) = dao.deleteWidget(appWidgetId)
@@ -114,6 +114,76 @@ object WColor {
val aqiBar6 = Color(0xFF8E24AA) val aqiBar6 = Color(0xFF8E24AA)
val uvDotLow = Color(0xFF5CCF8A) val uvDotLow = Color(0xFF5CCF8A)
val uvDotOff = Color(0xFF6B5A52) 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 { object WType {
@@ -1,22 +1,44 @@
package app.wetter.widget package app.wetter.widget
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap 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.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope 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.Density
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import app.wetter.data.Condition import app.wetter.data.Condition
import app.wetter.ui.scene.Particles import app.wetter.ui.components.wavyCircle
import app.wetter.ui.scene.SceneSpec
import app.wetter.ui.scene.drawSceneLayers
import app.wetter.ui.scene.drawWeatherIcon 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 { object WidgetArt {
private fun render(w: Int, h: Int, block: DrawScope.() -> Unit): Bitmap { private fun render(w: Int, h: Int, block: DrawScope.() -> Unit): Bitmap {
val image = ImageBitmap(w, h) 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) } 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. */ /** A stadium tilted by 45°, filling a square. */
fun scene(spec: SceneSpec, w: Int = 480, h: Int = 320): Bitmap = render(w, h) { fun tiltedPill(color: Color, px: Int = 400): Bitmap = render(px, px) {
drawRect(Brush.verticalGradient(listOf(spec.top, spec.bottom))) rotate(-45f) {
drawSceneLayers(spec, t = .2f, scroll = 0f, particles = Particles.make(spec.condition)) 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 package app.wetter.widget
import android.content.Context import android.content.Context
import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
@@ -12,18 +13,16 @@ import androidx.glance.Image
import androidx.glance.ImageProvider import androidx.glance.ImageProvider
import androidx.glance.LocalContext import androidx.glance.LocalContext
import androidx.glance.LocalSize import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode 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.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.Column import androidx.glance.layout.Column
@@ -42,7 +41,7 @@ import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextAlign import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider as FixedColor import androidx.glance.unit.ColorProvider
import app.wetter.MainActivity import app.wetter.MainActivity
import app.wetter.R import app.wetter.R
import app.wetter.data.Forecast import app.wetter.data.Forecast
@@ -52,102 +51,223 @@ import app.wetter.data.LocationEntity
import app.wetter.data.Settings import app.wetter.data.Settings
import app.wetter.data.Units import app.wetter.data.Units
import app.wetter.data.conditionOf import app.wetter.data.conditionOf
import app.wetter.data.intensityOf
import app.wetter.ui.components.conditionLabel 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 kotlinx.coroutines.flow.first
import java.time.LocalDate import kotlin.reflect.KClass
suspend fun updateAllWidgets(context: Context) { suspend fun updateAllWidgets(context: Context) {
NowWidget().updateAll(context) WeatherCardWidget().updateAll(context)
ForecastWidget().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) Graph.init(context)
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) // ids that are not real widgets (previews, the debug gallery) fall back to the current location
val location = Graph.repo.widgetLocation(appWidgetId) 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 forecast = location?.let { Graph.repo.forecastOnce(it.id) }
val settings = runCatching { Graph.settings.settings.first() }.getOrDefault(Settings()) 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 { /** Base: loads data once per update, renders [Content], and offers the same content as picker preview. */
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { abstract class WeatherWidget : GlanceAppWidget() {
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) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = load(context, id) val data = loadWidgetData(context, id)
provideContent { Content(data) } provideContent { Root(data) }
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
val data = loadWidgetData(context, null)
provideContent { Root(data) }
} }
@Composable @Composable
private fun Content(data: WidgetData) { fun Root(data: WidgetData) {
val context = LocalContext.current 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 val size = LocalSize.current
Frame(data) { spec -> val p = data.palette
val fg = spec.onScene val u = data.units
val f = data.forecast val ctx = LocalContext.current
if (f == null) { val today = f.today
Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(
Text(context.getString(R.string.widget_loading), style = text(fg, 16)) GlanceModifier.fillMaxSize().cornerRadius(28.dp).background(p.bg).clickable(open(data)).padding(16.dp)
} .semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
return@Frame ) {
} Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
val u = data.units Column(GlanceModifier.defaultWeight()) {
val cond = conditionOf(f.current.code) if (size.height >= LARGE.height && data.name.isNotEmpty()) {
val condText = context.getString(conditionLabel(cond, f.current.isDay)) Text(data.name, style = text(p.accent, 13, FontWeight.Medium), maxLines = 1)
val name = data.location?.name?.ifBlank { context.getString(R.string.current_location) } ?: "" Spacer(GlanceModifier.height(6.dp))
val summary = "$name ${u.temp(f.current.temp)} $condText" }
if (size.height < MEDIUM.height) { Row(verticalAlignment = Alignment.CenterVertically) {
Row( Icon(f, 30)
GlanceModifier.fillMaxSize().padding(horizontal = 16.dp).semantics { contentDescription = summary }, Spacer(GlanceModifier.width(8.dp))
verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally, Text(conditionText(f), style = text(p.onBg, 16, FontWeight.Medium), maxLines = 1)
) { }
Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(40.dp)) Spacer(GlanceModifier.height(4.dp))
Spacer(GlanceModifier.width(8.dp)) Text(ctx.getString(R.string.feels_like, u.temp(f.current.feelsLike)), style = text(p.onBg, 14), maxLines = 1)
Text(u.temp(f.current.temp), style = text(fg, 36, FontWeight.Bold)) if (today != null) Text("${u.temp(today.tMax)} · ${u.temp(today.tMin)}", style = text(p.muted, 14))
} }
} else { Text(u.temp(f.current.temp), style = text(p.onBg, if (size.width >= MEDIUM.width) 56 else 44))
Column(GlanceModifier.fillMaxSize().padding(16.dp).semantics { contentDescription = summary }) { }
Text(name, style = text(fg, 14, FontWeight.Medium), maxLines = 1) if (size.height < MEDIUM.height) {
Spacer(GlanceModifier.defaultWeight()) // compact: two hours under the temperature
Row(verticalAlignment = Alignment.CenterVertically) { Spacer(GlanceModifier.defaultWeight())
Text(u.temp(f.current.temp), style = text(fg, 44, FontWeight.Bold)) Row(GlanceModifier.fillMaxWidth()) {
Spacer(GlanceModifier.width(6.dp)) Spacer(GlanceModifier.defaultWeight())
Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(40.dp)) 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 { companion object {
val SMALL = DpSize(110.dp, 48.dp) 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() { // 3. Tilted pill: a 45° stadium, temperature top right, icon bottom left
override val sizeMode = SizeMode.Responsive(setOf(WIDE, TALL)) // ---------------------------------------------------------------------------------------------
override suspend fun provideGlance(context: Context, id: GlanceId) { class TiltWidget : WeatherWidget() {
val data = load(context, id) override val sizeMode = SizeMode.Exact
provideContent { Content(data) }
}
@Composable @Composable
private fun Content(data: WidgetData) { override fun Content(data: WidgetData, f: Forecast) {
val context = LocalContext.current val cond = conditionText(f)
val p = data.palette
val u = data.units
val size = LocalSize.current val size = LocalSize.current
Frame(data) { spec -> val side = minOf(size.width.value, size.height.value)
val fg = spec.onScene Box(
val f = data.forecast GlanceModifier.fillMaxSize().clickable(open(data))
if (f == null) { .semantics { contentDescription = "${data.name} ${u.temp(f.current.temp)} $cond" },
Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center,
Text(context.getString(R.string.widget_loading), style = text(fg, 16)) ) {
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()) { // 4. Bar: one line "today", condition, and a cookie badge with the current temperature
Text(name, style = text(fg, 14, FontWeight.Medium), maxLines = 1) // ---------------------------------------------------------------------------------------------
Text(context.getString(conditionLabel(cond, f.current.isDay)), style = text(fg, 12), maxLines = 1)
} class BarWidget : WeatherWidget() {
Text(u.temp(f.current.temp), style = text(fg, 36, FontWeight.Bold)) override val sizeMode = SizeMode.Exact
Image(ImageProvider(WidgetArt.icon(cond, f.current.isDay)), null, GlanceModifier.size(36.dp))
Image( @Composable
ImageProvider(R.drawable.ic_refresh), context.getString(R.string.retry), override fun Content(data: WidgetData, f: Forecast) {
GlanceModifier.size(28.dp).padding(4.dp).clickable(actionRunCallback<RefreshAction>()), val cond = conditionText(f)
colorFilter = androidx.glance.ColorFilter.tint(FixedColor(fg)), 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()) { Box(GlanceModifier.size(badge.dp), contentAlignment = Alignment.Center) {
f.upcomingHours(6).forEachIndexed { i, h -> Image(ImageProvider(WidgetArt.cookie(p.badge)), null, GlanceModifier.fillMaxSize())
Column(GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(if (i == 0) context.getString(R.string.now) else Fmt.hourLabel(h.time), style = text(fg, 11, align = TextAlign.Center)) Icon(f, (badge * .38f).toInt())
Image(ImageProvider(WidgetArt.icon(conditionOf(h.code), h.isDay, 96)), null, GlanceModifier.size(26.dp)) Text(u.temp(f.current.temp), style = text(p.onBadge, 13, FontWeight.Medium))
Text(u.temp(h.temp), style = text(fg, 13, FontWeight.Medium, TextAlign.Center)) }
}
}
}
}
// ---------------------------------------------------------------------------------------------
// 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) { if (wide) {
Spacer(GlanceModifier.height(12.dp)) Spacer(GlanceModifier.defaultWeight())
val today = f.daily.firstOrNull()?.date for (i in 1..4) HourCell(f, i, data, p.onBg, GlanceModifier.width(44.dp))
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 { companion object {
val SQUARE = DpSize(140.dp, 140.dp)
val WIDE = DpSize(250.dp, 110.dp) val WIDE = DpSize(250.dp, 110.dp)
val TALL = DpSize(250.dp, 250.dp)
} }
} }
class NowWidgetReceiver : GlanceAppWidgetReceiver() { class WeatherCardReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = WeatherCardWidget() }
override val glanceAppWidget: GlanceAppWidget = NowWidget() 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 ForecastWidgetReceiver : GlanceAppWidgetReceiver() { class ClassicReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = ClassicWidget() }
override val glanceAppWidget: GlanceAppWidget = ForecastWidget()
}
@@ -11,6 +11,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import app.wetter.data.Graph import app.wetter.data.Graph
import app.wetter.widget.publishWidgetPreviews
import app.wetter.widget.updateAllWidgets import app.wetter.widget.updateAllWidgets
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -21,6 +22,7 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorke
val force = inputData.getBoolean(KEY_FORCE, false) val force = inputData.getBoolean(KEY_FORCE, false)
runCatching { Graph.repo.refreshAll(force) } runCatching { Graph.repo.refreshAll(force) }
updateAllWidgets(applicationContext) updateAllWidgets(applicationContext)
publishWidgetPreviews(applicationContext)
return Result.success() return Result.success()
} }
+20 -4
View File
@@ -113,10 +113,26 @@
<string name="set_about">Datenquellen</string> <string name="set_about">Datenquellen</string>
<string name="attribution">Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende</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_card_name">Wetterkarte</string>
<string name="widget_now_desc">Temperatur und Wetterlage auf einen Blick</string> <string name="widget_card_desc">Jetzt, die nächsten Stunden und Tage. Wächst mit der Größe.</string>
<string name="widget_forecast_name">Vorhersage</string> <string name="widget_pill_name">Wetter-Pille</string>
<string name="widget_forecast_desc">Jetzt, die nächsten Stunden und Tage</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_pick">Ort wählen</string>
<string name="widget_follow">Mein Standort (folgt dir)</string> <string name="widget_follow">Mein Standort (folgt dir)</string>
<string name="widget_loading">…</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"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <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:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="110dp" android:minWidth="250dp" android:minHeight="110dp"
android:targetCellWidth="4" android:targetCellHeight="2" 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:resizeMode="horizontal|vertical"
android:updatePeriodMillis="3600000" android:updatePeriodMillis="3600000"
android:configure="app.wetter.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" /> 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" />