diff --git a/README.md b/README.md
index b065a0a..7bf755e 100644
--- a/README.md
+++ b/README.md
@@ -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 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.
-- **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).
-- **Coming next:** severe weather alerts (DWD / MeteoAlarm), more widget sizes and a widget configuration screen, precipitation radar map, card reordering, notifications.
+- **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.
+- **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.
diff --git a/replica/build-log.md b/replica/build-log.md
index 3fb85ce..7ad5fe0 100644
--- a/replica/build-log.md
+++ b/replica/build-log.md
@@ -12,7 +12,8 @@ Stack as in architecture.md. App in `wetter/`. Screenshots are rendered on the J
| S15 Preview | 2026-10-17 | done | – | – |
| 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) | – |
-| 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 | – |
Not yet verified on a real device: location without Play Services, network on device, widgets on the launcher, WorkManager timing.
diff --git a/replica/design/Tokens.kt b/replica/design/Tokens.kt
index 3eaf9db..3b7e5a8 100644
--- a/replica/design/Tokens.kt
+++ b/replica/design/Tokens.kt
@@ -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 {
diff --git a/replica/design/tokens.json b/replica/design/tokens.json
index 4fab572..515357e 100644
--- a/replica/design/tokens.json
+++ b/replica/design/tokens.json
@@ -106,7 +106,77 @@
"aqi-bar-5": "#e53935",
"aqi-bar-6": "#8e24aa",
"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": [
[
@@ -396,6 +466,166 @@
"nowcast-dot",
"card",
"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": {
diff --git a/replica/features.csv b/replica/features.csv
index 0bb7c85..b16d301 100644
--- a/replica/features.csv
+++ b/replica/features.csv
@@ -1,44 +1,44 @@
-feature,area,priority,original,clone,notes
-Current conditions hero (temp feels-like high/low condition),home,must,yes,yes,
-Condition-based animated background,home,must,yes,yes,original artwork not reusable; draw own
-Hourly forecast strip (24h+),home,must,yes,yes,fixed position
-10-day forecast list,home,must,yes,yes,
-Daily detail view (tap a day),home,should,yes,yes,guess: exists in original
-Precipitation card with chance and amount,cards,must,yes,yes,
-Wind card (speed direction gusts),cards,must,yes,yes,
-UV index card,cards,must,yes,yes,
-Humidity / dew point card,cards,must,yes,yes,
-Pressure card,cards,should,yes,yes,
-Visibility card,cards,should,yes,yes,
-Sunrise / sunset card,cards,must,yes,yes,
-Air quality (AQI) card,cards,should,yes,yes,Open-Meteo air-quality API (CAMS)
-Pollen card,cards,could,yes,yes,Open-Meteo pollen (Europe only)
-Drag to reorder cards,customize,should,yes,no,
-Card order saved per city,customize,could,yes,no,
-Location search by name or postal code,locations,must,yes,yes,Open-Meteo geocoding
-Current location via GPS,locations,must,yes,yes,no Google Play Services; use platform LocationManager
-Saved locations list,locations,must,yes,yes,
-Swipe to delete a location,locations,must,yes,yes,
-Swipe/pager between saved cities,locations,should,yes,yes,
-Temperature unit (default C F),settings,must,yes,yes,
-Units for precipitation wind pressure visibility,settings,should,yes,yes,
-Theme setting (system light dark),settings,should,yes,yes,
-Precipitation map with 6h nowcast and timeline scrubber,map,should,yes,no,RainViewer or DWD radar tiles + OSM base map
-Pan and zoom map,map,should,yes,no,
-Severe weather alerts banner on top,alerts,must,yes,no,DWD / MeteoAlarm feeds
-Severe weather notification,notifications,should,yes,no,background worker
-Evening tomorrow-forecast notification,notifications,could,yes,no,
-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"
-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,
-Tablet two-column layout,layout,could,yes,partial,content capped at 720dp and centred; no true two-pane
-Sync saved locations across devices via Google account,sync,could,yes,skip,no Google account by design
-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
-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 dynamic Material You colours and condition background,widgets,should,yes,partial,condition background only; dynamic colour style missing
-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
-Location preview with Save,locations,should,yes,yes,S15
+feature,area,priority,original,clone,notes
+Current conditions hero (temp feels-like high/low condition),home,must,yes,yes,
+Condition-based animated background,home,must,yes,yes,original artwork not reusable; draw own
+Hourly forecast strip (24h+),home,must,yes,yes,fixed position
+10-day forecast list,home,must,yes,yes,
+Daily detail view (tap a day),home,should,yes,yes,guess: exists in original
+Precipitation card with chance and amount,cards,must,yes,yes,
+Wind card (speed direction gusts),cards,must,yes,yes,
+UV index card,cards,must,yes,yes,
+Humidity / dew point card,cards,must,yes,yes,
+Pressure card,cards,should,yes,yes,
+Visibility card,cards,should,yes,yes,
+Sunrise / sunset card,cards,must,yes,yes,
+Air quality (AQI) card,cards,should,yes,yes,Open-Meteo air-quality API (CAMS)
+Pollen card,cards,could,yes,yes,Open-Meteo pollen (Europe only)
+Drag to reorder cards,customize,should,yes,no,
+Card order saved per city,customize,could,yes,no,
+Location search by name or postal code,locations,must,yes,yes,Open-Meteo geocoding
+Current location via GPS,locations,must,yes,yes,no Google Play Services; use platform LocationManager
+Saved locations list,locations,must,yes,yes,
+Swipe to delete a location,locations,must,yes,yes,
+Swipe/pager between saved cities,locations,should,yes,yes,
+Temperature unit (default C F),settings,must,yes,yes,
+Units for precipitation wind pressure visibility,settings,should,yes,yes,
+Theme setting (system light dark),settings,should,yes,yes,
+Precipitation map with 6h nowcast and timeline scrubber,map,should,yes,no,RainViewer or DWD radar tiles + OSM base map
+Pan and zoom map,map,should,yes,no,
+Severe weather alerts banner on top,alerts,must,yes,no,DWD / MeteoAlarm feeds
+Severe weather notification,notifications,should,yes,no,background worker
+Evening tomorrow-forecast notification,notifications,could,yes,no,
+Precipitation-starting notification per city,notifications,could,yes,no,needs minute nowcast
+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"
+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
+Sync saved locations across devices via Google account,sync,could,yes,skip,no Google account by design
+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
+At a Glance integration,widgets,could,yes,skip,Pixel launcher system feature
+Widget sizes small medium large with hourly/daily,widgets,must,yes,yes,Glance
+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,
+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
diff --git a/wetter/app/build.gradle.kts b/wetter/app/build.gradle.kts
index eaa9ba4..516f85f 100644
--- a/wetter/app/build.gradle.kts
+++ b/wetter/app/build.gradle.kts
@@ -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")
diff --git a/wetter/app/schemas/app.wetter.data.WeatherDb/2.json b/wetter/app/schemas/app.wetter.data.WeatherDb/2.json
new file mode 100644
index 0000000..da88507
--- /dev/null
+++ b/wetter/app/schemas/app.wetter.data.WeatherDb/2.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/wetter/app/src/debug/AndroidManifest.xml b/wetter/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..6cf3a97
--- /dev/null
+++ b/wetter/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt b/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt
new file mode 100644
index 0000000..25e145c
--- /dev/null
+++ b/wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt
@@ -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>> = 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())
+}
diff --git a/wetter/app/src/main/AndroidManifest.xml b/wetter/app/src/main/AndroidManifest.xml
index eadf7db..151addb 100644
--- a/wetter/app/src/main/AndroidManifest.xml
+++ b/wetter/app/src/main/AndroidManifest.xml
@@ -24,13 +24,29 @@
-
+
+
+
+
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/wetter/app/src/main/java/app/wetter/data/Db.kt b/wetter/app/src/main/java/app/wetter/data/Db.kt
index 64e7b79..8b80eaa 100644
--- a/wetter/app/src/main/java/app/wetter/data/Db.kt
+++ b/wetter/app/src/main/java/app/wetter/data/Db.kt
@@ -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'")
+ }
+ }
}
}
diff --git a/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt
index 1b2f964..1554e34 100644
--- a/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt
+++ b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt
@@ -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)
diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt
index 3eaf9db..3b7e5a8 100644
--- a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt
+++ b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt
@@ -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 {
diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt
index b62afab..5c9fa7a 100644
--- a/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt
+++ b/wetter/app/src/main/java/app/wetter/widget/WidgetArt.kt
@@ -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)
}
}
diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt
new file mode 100644
index 0000000..f3cc1cd
--- /dev/null
+++ b/wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt
@@ -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 = 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(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),
+)
diff --git a/wetter/app/src/main/java/app/wetter/widget/WidgetStyle.kt b/wetter/app/src/main/java/app/wetter/widget/WidgetStyle.kt
new file mode 100644
index 0000000..9d40331
--- /dev/null
+++ b/wetter/app/src/main/java/app/wetter/widget/WidgetStyle.kt
@@ -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, 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)
+}
diff --git a/wetter/app/src/main/java/app/wetter/widget/Widgets.kt b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
index c1212c2..eeacd53 100644
--- a/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
+++ b/wetter/app/src/main/java/app/wetter/widget/Widgets.kt
@@ -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>(
+ 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()),
- 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() }
diff --git a/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt
index e0fa771..20a1889 100644
--- a/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt
+++ b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt
@@ -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()
}
diff --git a/wetter/app/src/main/res/values/strings.xml b/wetter/app/src/main/res/values/strings.xml
index d955e98..489a1ce 100644
--- a/wetter/app/src/main/res/values/strings.xml
+++ b/wetter/app/src/main/res/values/strings.xml
@@ -113,10 +113,26 @@
Datenquellen
Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende
- Wetter jetzt
- Temperatur und Wetterlage auf einen Blick
- Vorhersage
- Jetzt, die nächsten Stunden und Tage
+ Wetterkarte
+ Jetzt, die nächsten Stunden und Tage. Wächst mit der Größe.
+ Wetter-Pille
+ Icon und Temperatur in einer Pille
+ Schräge Pille
+ Temperatur in einer schrägen Form
+ Wetterleiste
+ Heute auf einen Blick, eine Zeile
+ Klassisch
+ Ort, Wetterlage und die nächsten Stunden
+ Widget anpassen
+ Ort
+ Farben
+ Deckkraft des Hintergrunds
+ Bei wenig Deckkraft hängt die Lesbarkeit vom Hintergrundbild ab.
+ Fertig
+
+ - Wallpaper
- Wetterlage
- Ozean
- Wald
- Sonne
- Lavendel
- Graphit
+
+ Quelle: Open-Meteo
Ort wählen
Mein Standort (folgt dir)
…
diff --git a/wetter/app/src/main/res/xml/widget_bar.xml b/wetter/app/src/main/res/xml/widget_bar.xml
new file mode 100644
index 0000000..c8eb2e4
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_bar.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/wetter/app/src/main/res/xml/widget_card.xml b/wetter/app/src/main/res/xml/widget_card.xml
new file mode 100644
index 0000000..81fe6b5
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_card.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/wetter/app/src/main/res/xml/widget_forecast.xml b/wetter/app/src/main/res/xml/widget_classic.xml
similarity index 57%
rename from wetter/app/src/main/res/xml/widget_forecast.xml
rename to wetter/app/src/main/res/xml/widget_classic.xml
index 384fd96..a20363a 100644
--- a/wetter/app/src/main/res/xml/widget_forecast.xml
+++ b/wetter/app/src/main/res/xml/widget_classic.xml
@@ -1,10 +1,13 @@
diff --git a/wetter/app/src/main/res/xml/widget_now.xml b/wetter/app/src/main/res/xml/widget_now.xml
deleted file mode 100644
index 1b1a2a7..0000000
--- a/wetter/app/src/main/res/xml/widget_now.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
diff --git a/wetter/app/src/main/res/xml/widget_pill.xml b/wetter/app/src/main/res/xml/widget_pill.xml
new file mode 100644
index 0000000..b54cdab
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_pill.xml
@@ -0,0 +1,13 @@
+
+
diff --git a/wetter/app/src/main/res/xml/widget_tilt.xml b/wetter/app/src/main/res/xml/widget_tilt.xml
new file mode 100644
index 0000000..3aa78f1
--- /dev/null
+++ b/wetter/app/src/main/res/xml/widget_tilt.xml
@@ -0,0 +1,13 @@
+
+