From 0c42d8570b4f591a7ef7f50f9ddcb9e7148bb734 Mon Sep 17 00:00:00 2001 From: Louis Date: Sat, 17 Oct 2026 14:24:52 +0200 Subject: [PATCH] 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 --- README.md | 5 +- replica/build-log.md | 3 +- replica/design/Tokens.kt | 70 +++ replica/design/tokens.json | 232 ++++++++- replica/features.csv | 88 ++-- wetter/app/build.gradle.kts | 4 +- .../schemas/app.wetter.data.WeatherDb/2.json | 214 +++++++++ wetter/app/src/debug/AndroidManifest.xml | 6 + .../java/app/wetter/WidgetGalleryActivity.kt | 65 +++ wetter/app/src/main/AndroidManifest.xml | 24 +- .../app/src/main/java/app/wetter/data/Db.kt | 17 +- .../java/app/wetter/data/WeatherRepository.kt | 5 +- .../main/java/app/wetter/ui/theme/Tokens.kt | 70 +++ .../main/java/app/wetter/widget/WidgetArt.kt | 48 +- .../app/wetter/widget/WidgetConfigActivity.kt | 208 ++++++++ .../java/app/wetter/widget/WidgetStyle.kt | 81 ++++ .../main/java/app/wetter/widget/Widgets.kt | 451 ++++++++++++------ .../java/app/wetter/work/RefreshWorker.kt | 2 + wetter/app/src/main/res/values/strings.xml | 24 +- wetter/app/src/main/res/xml/widget_bar.xml | 13 + wetter/app/src/main/res/xml/widget_card.xml | 13 + ...widget_forecast.xml => widget_classic.xml} | 7 +- wetter/app/src/main/res/xml/widget_now.xml | 10 - wetter/app/src/main/res/xml/widget_pill.xml | 13 + wetter/app/src/main/res/xml/widget_tilt.xml | 13 + 25 files changed, 1459 insertions(+), 227 deletions(-) create mode 100644 wetter/app/schemas/app.wetter.data.WeatherDb/2.json create mode 100644 wetter/app/src/debug/AndroidManifest.xml create mode 100644 wetter/app/src/debug/java/app/wetter/WidgetGalleryActivity.kt create mode 100644 wetter/app/src/main/java/app/wetter/widget/WidgetConfigActivity.kt create mode 100644 wetter/app/src/main/java/app/wetter/widget/WidgetStyle.kt create mode 100644 wetter/app/src/main/res/xml/widget_bar.xml create mode 100644 wetter/app/src/main/res/xml/widget_card.xml rename wetter/app/src/main/res/xml/{widget_forecast.xml => widget_classic.xml} (57%) delete mode 100644 wetter/app/src/main/res/xml/widget_now.xml create mode 100644 wetter/app/src/main/res/xml/widget_pill.xml create mode 100644 wetter/app/src/main/res/xml/widget_tilt.xml 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 + + WallpaperWetterlageOzeanWaldSonneLavendelGraphit + + 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 @@ + +