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