replica: recon, architecture, design tokens, build log, reference and clone screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@@ -0,0 +1,110 @@
|
||||
# Architecture: Wetter-Klon (Android, ohne Google)
|
||||
|
||||
Basis: `recon.md`, `features.csv`. Schwerpunkt: **modernes Design und Homescreen-Widgets**.
|
||||
Es gibt keinen Server, keine Accounts und keine Zahlungen. Die App läuft komplett auf dem Gerät und spricht nur öffentliche, schlüsselfreie APIs an.
|
||||
|
||||
## Stack
|
||||
|
||||
| Schicht | Wahl | warum |
|
||||
| --- | --- | --- |
|
||||
| Sprache/UI | Kotlin + Jetpack Compose, **Material 3 Expressive** | native Animationen, Shape-Morphing, Dynamic Color: genau der Look des Originals |
|
||||
| Hintergründe | Compose Canvas + **AGSL Runtime Shaders** (API 33+), Fallback auf Canvas-Partikel | flüssiger Regen/Schnee/Wolken/Sonne bei 60–120 fps, eigene Grafik statt Google-Assets |
|
||||
| Widgets | **Jetpack Glance** (+ RemoteViews nur wo nötig) | Compose-artige Widget-API, responsive Größen, Dynamic Color |
|
||||
| Persistenz | Room (SQLite) + DataStore | eine lokale DB, offline-first |
|
||||
| Netzwerk | Ktor Client + kotlinx.serialization | leicht, keine Google-Abhängigkeit |
|
||||
| Hintergrundjobs | WorkManager | Refresh, Warnungen, Widget-Updates ohne Play Services |
|
||||
| Standort | `android.location.LocationManager` (kein Fused Provider) | funktioniert ohne GMS (GrapheneOS, /e/, LineageOS) |
|
||||
| Karte | MapLibre Native + OSM-/OpenFreeMap-Tiles, RainViewer-Radarkacheln | Open Source, keine Google Maps |
|
||||
| DI | Hilt oder manuell (Koin) | Koin: weniger Magie, reicht für eine App |
|
||||
| Build/Verteilung | Gradle KTS, minSdk 29, targetSdk aktuell, F-Droid-tauglich (keine proprietären Libs) | |
|
||||
|
||||
## Datenquellen (offiziell, öffentlich)
|
||||
|
||||
| Zweck | API | Hinweise |
|
||||
| --- | --- | --- |
|
||||
| Vorhersage aktuell/stündlich/10 Tage | Open-Meteo `/v1/forecast` (`models=best_match`, für DE `icon_seamless`) | kostenlos nichtkommerziell, CC BY 4.0, Attribution in den Einstellungen |
|
||||
| Minutengenauer Niederschlag | Open-Meteo `minutely_15` | Basis für „Regen in 20 Min“ |
|
||||
| Luftqualität + Pollen | Open-Meteo Air Quality API | Pollen nur in Europa |
|
||||
| Ortssuche | Open-Meteo Geocoding API | Name/PLZ |
|
||||
| Reverse Geocoding (GPS → Name) | Nominatim (OSM), max. 1 Req/s, eigener User-Agent | nur beim Standortwechsel |
|
||||
| Warnungen DE | DWD Warnungen (`warnings.json`/CAP) über Bright Sky API | |
|
||||
| Warnungen EU | MeteoAlarm CAP/Atom Feeds | |
|
||||
| Radar | RainViewer Weather Maps API (Vergangenheit + Nowcast) | Attribution Pflicht |
|
||||
|
||||
## Schema
|
||||
|
||||
Siehe `schema.sql`: 6 Tabellen (`locations`, `card_layout`, `forecast_cache`, `alerts`, `widget_config`, `notification_pref`), die Einstellungen liegen in DataStore.
|
||||
Zugriffsregeln: Es gibt keinen Mehrbenutzerbetrieb, Row Level Security entfällt. Die Daten liegen app-privat im Sandbox-Speicher, Backup über Auto Backup ist optional (ausschaltbar).
|
||||
Harte Constraints: nur ein `is_current`-Ort (partieller Unique-Index), keine doppelten Orte (gerundete Koordinaten), Warnungen werden über die Provider-ID dedupliziert.
|
||||
|
||||
## Interne „API“ (Repositories / Use Cases)
|
||||
|
||||
| Aufruf | macht | Input | Output | Flow |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ForecastRepo.observe(locId)` | Flow aus Cache, löst Refresh aus, wenn älter als 30 min | locId | `Forecast` + Status (fresh/stale/offline) | F01 |
|
||||
| `ForecastRepo.refresh(locId, force)` | Open-Meteo forecast + air laden, Cache schreiben | locId | Result | F01 |
|
||||
| `LocationRepo.updateCurrent()` | GPS → Reverse Geocoding → `is_current`-Zeile | – | Location | F01 |
|
||||
| `LocationRepo.search(q)` | Geocoding, entprellt 300 ms | query | List<Place> | F02 |
|
||||
| `LocationRepo.add/delete/reorder` | CRUD + Standard-`card_layout` anlegen | Place / id / order | – | F02, F03, F04 |
|
||||
| `LayoutRepo.move(locId, card, toPos)` | Kartenreihenfolge pro Stadt | – | – | F07 |
|
||||
| `AlertRepo.sync(locId)` | DWD/MeteoAlarm abrufen, abgelaufene löschen | locId | List<Alert> | F08 |
|
||||
| `RadarRepo.frames()` | RainViewer-Frame-Liste (−2 h … +30 min) | – | Frames | F05 |
|
||||
| `BriefGenerator.build(forecast, alerts)` | regelbasierter Satz („Ab 15 Uhr Regen, morgen 8° kühler“) | Forecast | String | S01 |
|
||||
| `SettingsRepo` | Einheiten, Theme, Animationen | – | Flow<Settings> | F06 |
|
||||
|
||||
### Hintergrundjobs (WorkManager)
|
||||
|
||||
| Job | Takt | macht |
|
||||
| --- | --- | --- |
|
||||
| `RefreshWorker` | periodisch 30 min (Netz vorhanden), Batterie nicht niedrig | alle Orte + Widgets aktualisieren |
|
||||
| `AlertWorker` | 15 min | Warnungen abrufen, neue severe/extreme → Notification |
|
||||
| `RainSoonWorker` | 15 min, nur Orte mit `rain_alerts` | `minutely_15` prüfen → „Regen in ~X Min“ |
|
||||
| `TomorrowWorker` | täglich zur eingestellten Uhrzeit | Morgen-Vorhersage als Notification |
|
||||
| Widget-Update | nach jedem Refresh + `updatePeriodMillis` als Fallback | `GlanceAppWidget.updateAll()` |
|
||||
|
||||
## Design-Architektur (Priorität)
|
||||
|
||||
- **Theme:** Material 3 Expressive, Dynamic Color (Monet) **plus** eine eigene, zur Wetterlage passende Farbpalette (z. B. klar/Tag → Blau-Gelb, Gewitter → Violett-Grau). `WeatherTheme` liefert Tokens aus `replica-design`.
|
||||
- **Hintergrund-Engine:** `WeatherScene(condition, isDay, intensity, windDir)` → AGSL-Shader-Layer (Himmelsverlauf, Wolken, Niederschlagspartikel, Blitz, Nebel), mit Parallax beim Scrollen. Bei aktiviertem Battery Saver bzw. „Animationen reduzieren“ wird ein statischer Verlauf gezeigt.
|
||||
- **Karten:** wiederverwendbare `MetricCard` mit expressiven Formen (Cookie-/Pill-Shapes), eigene Gauges (UV-Bogen, Windkompass, Sonnenbogen, AQI-Skala) in Canvas, alles eigene Icons (z. B. Material Symbols, Apache 2.0, plus eigene Wettericons).
|
||||
- **Motion:** Shared-Element-Übergang von der Städteliste zur Stadt, Kartendrag mit Spring-Animationen, Haptik beim Einrasten.
|
||||
- **Edge-to-Edge**, prädiktive Zurück-Geste, Two-Pane-Layout ab Breite „medium“ (Tablet/Foldable).
|
||||
|
||||
## Widgets (Priorität)
|
||||
|
||||
| Widget | Größen | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| Jetzt | 2×1, 2×2 | Icon, Temperatur, H/T, Ort |
|
||||
| Stündlich | 4×2 | Jetzt + 5–6 Stunden |
|
||||
| Täglich | 4×2, 4×3 | Jetzt + 5 Tage mit Temperaturbalken |
|
||||
| Groß/Komplett | 4×4 | Jetzt, stündlich, täglich, Warnhinweis |
|
||||
| Pille/Glance | 4×1 | Datum + Wetter in einer Zeile (eigener At-a-Glance-Ersatz) |
|
||||
|
||||
- Responsive `SizeMode.Responsive`, ein Widget-Typ pro Inhalt mit Größen-Breakpoints.
|
||||
- Stile: Dynamic Color, Wetterlage als Hintergrund (vorgerenderte Bitmaps, weil Widgets keine Shader können), transparent.
|
||||
- Konfigurations-Activity: Ort wählen (oder „aktueller Standort“) und den Stil, mit Live-Vorschau.
|
||||
- Tippen öffnet direkt die jeweilige Stadt, das Refresh-Icon stößt `RefreshWorker` an.
|
||||
|
||||
## Was beißt
|
||||
|
||||
- **Zeitzonen/DST:** Stunden und Tage immer in der Zeitzone des Orts (`timezone=auto`) zeigen, nicht in der des Geräts.
|
||||
- **Doze/Akkuoptimierung:** WorkManager-Takte sind nicht garantiert. Bei OEMs mit aggressivem Energiesparen auf die Ausnahme hinweisen.
|
||||
- **Ohne Play Services:** Kein Fused Location. Für schnelle Ergebnisse `getCurrentLocation` des Netzwerk-Providers mit Timeout nutzen, sonst die letzte bekannte Position.
|
||||
- **Rate Limits:** Open-Meteo erlaubt 10k Aufrufe am Tag, kein Problem. Nominatim erlaubt 1 Req/s, also cachen. RainViewer-Tiles müssen gecacht werden.
|
||||
- **Widget-Grenzen:** RemoteViews können keine Animationen und keine Shader, daher Hintergründe vorrendern. Updates sind limitiert, also ereignisgesteuert aktualisieren.
|
||||
- **Offline:** Der Cache wird immer angezeigt, mit dem Stand „vor X Min“. Ab 3 h wird er als veraltet markiert.
|
||||
- **Lizenz/Attribution:** Open-Meteo, DWD, RainViewer und OSM kommen in die About-Seite und ins Kartenoverlay.
|
||||
- **Performance:** Shader auf alten Geräten drosseln (adaptive Qualität), Hintergrund pausieren, wenn er nicht sichtbar ist.
|
||||
- **DSGVO:** Es gibt kein Tracking und kein Analytics. Koordinaten gehen nur an die Wetter-APIs, auf 2 Nachkommastellen gerundet.
|
||||
|
||||
## Build-Reihenfolge
|
||||
|
||||
1. **Vertical Slice** (S01, S14): GPS → Open-Meteo → Room → Home mit Hero, stündlicher Leiste und 10-Tage-Liste, schlicht gestaltet. Tabellen `locations`, `forecast_cache`. Beweist den Stack ohne GMS.
|
||||
2. **Design-Fundament** (S01): Theme, `WeatherScene`-Shader für die 6 Grundlagen (klar, bewölkt, Regen, Schnee, Gewitter, Nebel × Tag/Nacht), `MetricCard` und alle Must-Karten.
|
||||
3. **Widgets** (S12): Jetzt, Stündlich, Täglich, Konfiguration, `RefreshWorker`. Tabelle `widget_config`.
|
||||
4. **Orte** (S02, S03): Suche, Hinzufügen, Swipe-to-Delete, Pager zwischen Städten.
|
||||
5. **Warnungen** (S11, S13): DWD/MeteoAlarm, Banner, `AlertWorker`. Tabelle `alerts`.
|
||||
6. **Einstellungen** (S07–S09): Einheiten, Theme, Animationen.
|
||||
7. **Should:** Radar (S06), Kartenreihenfolge (S10, `card_layout`), AQI, Druck, Sicht, Detailansichten (S04, S05), großes Widget und Pillen-Widget, Regen-Benachrichtigung.
|
||||
8. **Could:** Brief, Pollen, Morgen-Benachrichtigung, Vibrationen, Tablet-Layout.
|
||||
9. **Fixes** aus `/replica-entrepreneur`.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Build log
|
||||
|
||||
Stack as in architecture.md. App in `wetter/`. Screenshots are rendered on the JVM (Roborazzi/Robolectric, no KVM for an emulator) from a real Open-Meteo response: `./gradlew :app:recordRoborazziDebug` → `replica/clone-screens/`.
|
||||
|
||||
| ID | date | status | missing | harder than expected |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| shell | 2026-10-17 | done | – | no JDK on the machine at first; no emulator (no /dev/kvm) → JVM screenshots instead of device screenshots |
|
||||
| S01 Home | 2026-10-17 | partial | alert banner (no alerts yet), card reordering (S10), true two-pane on tablets | brief contradicted the nowcast while it rained (fixed); sun curve was inverted (fixed) |
|
||||
| S14 Permissions | 2026-10-17 | done | – | – |
|
||||
| S02 Locations | 2026-10-17 | done | drag reordering of cities | – |
|
||||
| S03 Search | 2026-10-17 | done | recent searches | – |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 843 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 976 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 887 KiB |
@@ -0,0 +1,157 @@
|
||||
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
|
||||
package app.wetter.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object WColor {
|
||||
val bg = Color(0xFFF8F9FF)
|
||||
val surface = Color(0xFFFFFFFF)
|
||||
val surfaceContainer = Color(0xFFECEEF4)
|
||||
val surfaceContainerHigh = Color(0xFFE1E3EA)
|
||||
val border = Color(0xFFC3C6CF)
|
||||
val borderInput = Color(0xFF73777F)
|
||||
val text = Color(0xFF191C20)
|
||||
val textMuted = Color(0xFF43474E)
|
||||
val accent = Color(0xFF2A5EA8)
|
||||
val onAccent = Color(0xFFFFFFFF)
|
||||
val accentContainer = Color(0xFFD6E3FF)
|
||||
val onAccentContainer = Color(0xFF001B3E)
|
||||
val danger = Color(0xFFBA1A1A)
|
||||
val warning = Color(0xFF8A5100)
|
||||
val success = Color(0xFF2E6B30)
|
||||
val darkBg = Color(0xFF111318)
|
||||
val darkSurface = Color(0xFF1D2024)
|
||||
val darkSurfaceContainer = Color(0xFF272A2F)
|
||||
val darkBorder = Color(0xFF43474E)
|
||||
val darkBorderInput = Color(0xFF8D9199)
|
||||
val darkText = Color(0xFFE2E2E9)
|
||||
val darkTextMuted = Color(0xFFC3C6CF)
|
||||
val darkAccent = Color(0xFFA9C7FF)
|
||||
val darkOnAccent = Color(0xFF003063)
|
||||
val darkDanger = Color(0xFFFFB4AB)
|
||||
val darkWarning = Color(0xFFFFB870)
|
||||
val sceneClearDayTop = Color(0xFF7CC4EF)
|
||||
val sceneClearDayBottom = Color(0xFFBFE3F7)
|
||||
val sceneClearNightTop = Color(0xFF3D4F9A)
|
||||
val sceneClearNightBottom = Color(0xFF5B4F8F)
|
||||
val sceneCloudyDayTop = Color(0xFFC9CDD2)
|
||||
val sceneCloudyDayBottom = Color(0xFFDFE2E5)
|
||||
val sceneRainTop = Color(0xFF2E3D4F)
|
||||
val sceneRainBottom = Color(0xFF5A6B7D)
|
||||
val sceneSnowTop = Color(0xFF7C8EA3)
|
||||
val sceneSnowBottom = Color(0xFFD4DDE6)
|
||||
val sceneStormTop = Color(0xFF1F1A33)
|
||||
val sceneStormBottom = Color(0xFF4A3F63)
|
||||
val sceneFogTop = Color(0xFF8A929B)
|
||||
val sceneFogBottom = Color(0xFFC4C9CE)
|
||||
val onScene = Color(0xFFFFFFFF)
|
||||
val onSceneMuted = Color(0xFFE8EDF3)
|
||||
val onSceneDark = Color(0xFF14181D)
|
||||
val sceneCard = Color(0xFF2A3644)
|
||||
val sceneCardLight = Color(0xFFF2F5F8)
|
||||
val alertMinor = Color(0xFFFFF2B8)
|
||||
val onAlertMinor = Color(0xFF3D2F00)
|
||||
val alertSevere = Color(0xFFFFDBC8)
|
||||
val onAlertSevere = Color(0xFF4A1A00)
|
||||
val alertExtreme = Color(0xFF93000A)
|
||||
val onAlertExtreme = Color(0xFFFFFFFF)
|
||||
val aqiGood = Color(0xFF2E7D32)
|
||||
val aqiModerate = Color(0xFFA06A00)
|
||||
val aqiBad = Color(0xFFC62828)
|
||||
val uvHigh = Color(0xFFB34700)
|
||||
val card = Color(0xFFFDF8F3)
|
||||
val onCard = Color(0xFF1F1B17)
|
||||
val onCardMuted = Color(0xFF5A524B)
|
||||
val darkCard = Color(0xFF1F1714)
|
||||
val darkOnCard = Color(0xFFEDE0DB)
|
||||
val darkOnCardMuted = Color(0xFFC4B6B0)
|
||||
val fillPrimary = Color(0xFFD3E3FD)
|
||||
val onFillPrimary = Color(0xFF0B2A55)
|
||||
val fillSecondary = Color(0xFFE9DDFF)
|
||||
val onFillSecondary = Color(0xFF2A1A4D)
|
||||
val fillTertiary = Color(0xFFC8E6C2)
|
||||
val onFillTertiary = Color(0xFF0F3A12)
|
||||
val darkFillPrimary = Color(0xFFA8C7FA)
|
||||
val darkOnFillPrimary = Color(0xFF062E6F)
|
||||
val darkFillTertiary = Color(0xFF2D5A27)
|
||||
val darkOnFillTertiary = Color(0xFFB7F0AC)
|
||||
val darkFillOlive = Color(0xFF3E4A1E)
|
||||
val pill = Color(0xFFF1EBE5)
|
||||
val darkPill = Color(0xFF2B221E)
|
||||
val pillSelected = Color(0xFFA8C7FA)
|
||||
val onPillSelected = Color(0xFF0B2A55)
|
||||
val nowChip = Color(0xFF4E4038)
|
||||
val onNowChip = Color(0xFFFFFFFF)
|
||||
val precipText = Color(0xFF2A5EA8)
|
||||
val darkPrecipText = Color(0xFFA8C7FA)
|
||||
val nowcastDot = Color(0xFF2E7D32)
|
||||
val iconSun = Color(0xFFFFB02E)
|
||||
val iconSunCore = Color(0xFFFF9A1F)
|
||||
val iconMoon = Color(0xFFA9C4FF)
|
||||
val iconCloud = Color(0xFFF4F6F9)
|
||||
val iconCloudShade = Color(0xFFCFD6DF)
|
||||
val iconCloudDark = Color(0xFF9AA5B3)
|
||||
val iconDrop = Color(0xFF3D7BF0)
|
||||
val iconSnow = Color(0xFFFFFFFF)
|
||||
val iconSnowOutline = Color(0xFF9FB3CC)
|
||||
val iconBolt = Color(0xFFFFC93C)
|
||||
val iconFog = Color(0xFFC3CAD3)
|
||||
val sceneGlowSun = Color(0xFFFFF3C4)
|
||||
val sceneCloud = Color(0xFFFFFFFF)
|
||||
val sceneHill = Color(0xFF9FC5E6)
|
||||
val darkSceneHill = Color(0xFF2F3F78)
|
||||
val rangeCold = Color(0xFF6FA8FF)
|
||||
val rangeWarm = Color(0xFFFFB02E)
|
||||
val aqiBar1 = Color(0xFF4FD1C5)
|
||||
val aqiBar2 = Color(0xFF9CCC65)
|
||||
val aqiBar3 = Color(0xFFFFD54F)
|
||||
val aqiBar4 = Color(0xFFFF8A65)
|
||||
val aqiBar5 = Color(0xFFE53935)
|
||||
val aqiBar6 = Color(0xFF8E24AA)
|
||||
val uvDotLow = Color(0xFF5CCF8A)
|
||||
val uvDotOff = Color(0xFF6B5A52)
|
||||
}
|
||||
|
||||
object WType {
|
||||
val labelSm = TextStyle(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
|
||||
val label = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
|
||||
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
|
||||
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
|
||||
val title = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500)
|
||||
val headline = TextStyle(fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.W500)
|
||||
val metric = TextStyle(fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.W500)
|
||||
val display = TextStyle(fontSize = 112.sp, lineHeight = 112.sp, fontWeight = FontWeight.W700)
|
||||
val displayWidget = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
|
||||
val dayRange = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
|
||||
val heroCondition = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
|
||||
val cardTitle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500)
|
||||
}
|
||||
|
||||
object WSpace {
|
||||
val s0 = 0.dp
|
||||
val s2 = 2.dp
|
||||
val s4 = 4.dp
|
||||
val s8 = 8.dp
|
||||
val s12 = 12.dp
|
||||
val s16 = 16.dp
|
||||
val s20 = 20.dp
|
||||
val s24 = 24.dp
|
||||
val s32 = 32.dp
|
||||
val s48 = 48.dp
|
||||
}
|
||||
|
||||
object WRadius {
|
||||
val xs = 8.dp
|
||||
val sm = 12.dp
|
||||
val md = 16.dp
|
||||
val lg = 24.dp
|
||||
val xl = 28.dp
|
||||
val widget = 24.dp
|
||||
val pill = 999.dp
|
||||
val card = 28.dp
|
||||
val dayPill = 24.dp
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# Komponenten: Wetter-Klon (Compose, Material 3 Expressive)
|
||||
|
||||
Tokens: `tokens.json` → `Tokens.kt` (über `gen_theme.py`). Komponenten verwenden nur Rollen (`WColor.onScene`), nie rohe Hex-Werte.
|
||||
Icons: Material Symbols Rounded (Apache 2.0) für die UI. Die Wettericons sind eigene oder [Meteocons](https://github.com/basmilius/weather-icons) (MIT), animiert über Lottie. Kein Google-Artwork.
|
||||
Schrift: Roboto Flex (OFL). Texte sind alle neu geschrieben, auf Deutsch.
|
||||
|
||||
## Grundregel Kontrast über Szenen
|
||||
|
||||
Text auf dem animierten Himmel nutzt `on-scene`, bei hellen Szenen (Schnee, Nebel, unterer Tagesverlauf) `on-scene-dark`. Die Szene meldet ihre Helligkeit (`scene.isLight`), und das Theme wählt danach.
|
||||
Beim klaren Tageshimmel oben besteht weißer Text nur als Großtext (3,9:1). Kleiner Text dort braucht deshalb einen Scrim, einen Verlauf von 0 auf 25 % Schwarz hinter dem Hero-Kopf.
|
||||
Karten auf der Szene: `scene-card` mit 85 % Deckkraft plus Blur (API 31+, `RenderEffect`). Ohne Blur wird `scene-card` voll deckend gezeigt.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
WeatherScene (animierter Hintergrund)
|
||||
variants clear, partly-cloudy, cloudy, rain, drizzle, snow, sleet, storm, fog × day/night; intensity 0..1
|
||||
layers sky gradient (scene-*-top/bottom) → sun/moon+stars → clouds (AGSL noise) → precipitation particles → lightning flash → fog
|
||||
states playing, paused (off-screen / lifecycle STOPPED), reduced (battery saver, "Animationen entfernen", setting off → static gradient), transitioning (crossfade 600ms between conditions)
|
||||
motion parallax 0.3× on scroll, wind direction tilts rain, haptic tick on lightning (if vibrations on, never during severe alert)
|
||||
a11y decorative: no semantics; condition always also stated in text
|
||||
perf adaptive quality (particle count by frame time), 30fps cap on low-end
|
||||
used on S01, S02 (row backgrounds, static), S12 (pre-rendered bitmap)
|
||||
|
||||
HeroBlock
|
||||
content city name (title) + location pin if current, temperature (display 96sp, wdth 125), condition (headline), "Gefühlt 12°" + H/T (body)
|
||||
states loading (shimmer on numbers, scene neutral), filled, stale ("Stand: vor 2 Std" chip), offline (cloud-off icon chip), no-permission (CTA "Standort erlauben")
|
||||
tokens on-scene / on-scene-dark, space 24 top
|
||||
motion temperature counts up on first load (300ms), collapses into app bar on scroll (large → small top app bar)
|
||||
a11y one merged semantics node: "Berlin, 14 Grad, leicht bewölkt, gefühlt 12, Höchstwert 17, Tiefstwert 9"
|
||||
used on S01
|
||||
|
||||
AlertBanner
|
||||
variants minor (alert-minor), moderate/severe (alert-severe), extreme (alert-extreme)
|
||||
content icon, event ("Sturmböen"), agency + validity ("DWD · bis 18:00"), chevron
|
||||
states single, stacked (n more), expanded (inline first lines), expired (removed with fade)
|
||||
tokens radius lg, padding 16
|
||||
a11y liveRegion polite on appear; whole banner is one button "Warnung: Sturmböen, Deutscher Wetterdienst, bis 18 Uhr. Details"
|
||||
used on S01 (top of cards, replaces Brief), S11
|
||||
|
||||
BriefCard
|
||||
content 1–2 sentences, generated (e.g. "Ab 15 Uhr Regen, abends trocken. Morgen 6° kühler.")
|
||||
states loading (two shimmer lines), filled, hidden (alert active or setting off)
|
||||
tokens scene-card, body, radius xl
|
||||
used on S01
|
||||
|
||||
HourlyStrip
|
||||
content per hour: time label, icon, temp, precip % if ≥ 20 %; sunrise/sunset inserted as items; "Jetzt" first
|
||||
states loading, filled, scroll; selected-hour (tap shows detail tooltip)
|
||||
extra temperature line chart behind items (Canvas, smooth cubic)
|
||||
a11y LazyRow, each item "15 Uhr, 14 Grad, Regen 40 Prozent"
|
||||
used on S01 (fixed position), S04, S12 (widget variant)
|
||||
|
||||
DayRow / DailyList
|
||||
content weekday ("Heute", "Mo"), icon, precip %, low temp, range bar (gradient cold→warm over the 10-day min..max, dot = current temp today), high temp
|
||||
states default, pressed (ripple), expanded (inline hourly, optional)
|
||||
a11y "Montag, Regen 60 Prozent, 8 bis 15 Grad"
|
||||
used on S01, S12
|
||||
|
||||
MetricCard (base for all detail cards)
|
||||
variants small 1×1, wide 2×1; shapes per tokens.shape (squircle, cookie, arch, pill)
|
||||
slots header (icon + label), value (metric 32sp), visual (gauge/chart), footer (one-line explanation)
|
||||
states default, pressed (morph radius→md, spring-fast), dragging (elevation card-drag, scale 1.04, haptic), placeholder (dashed border while dragging), loading, unavailable ("In deiner Region nicht verfügbar")
|
||||
tokens scene-card / scene-card-light, on-scene, radius lg
|
||||
a11y button → opens S05; reorder via accessibility actions "Nach oben/unten verschieben"
|
||||
used on S01, S10
|
||||
|
||||
instances
|
||||
Precip next-hour bar chart (minutely_15) + "Regen in ~20 Min" / total today
|
||||
Wind compass dial with arrow, speed, gusts, Beaufort label
|
||||
UV cookie shape, arc 0–11+, colour by band (uv-high etc.), "Sonnenschutz 11–16 Uhr"
|
||||
Humidity value + dew point, vertical fill
|
||||
Pressure dial with trend arrow (rising/falling over 3h)
|
||||
Visibility value + description
|
||||
Sun arch with sun position dot, sunrise/sunset times, day length
|
||||
AQI pill scale (aqi-good..aqi-bad), value, main pollutant
|
||||
Pollen top 3 types with level dots (Europe only, else hidden)
|
||||
Map static radar preview thumbnail → opens S06
|
||||
|
||||
RadarMap (S06)
|
||||
content MapLibre + OSM base (light/dark style), RainViewer overlay, location dot, attribution
|
||||
controls timeline scrubber (past 2h … +30min), play/pause FAB, time label, legend
|
||||
states loading tiles, playing, paused, no-coverage, offline
|
||||
a11y scrubber is a Slider with "13:40, Vorhersage" values
|
||||
used on S06
|
||||
|
||||
CityRow
|
||||
content name, admin area, local time, temp, condition icon, H/T; current-location row with pin and not deletable
|
||||
states default, pressed, swiping (red bg + trash icon reveal), deleted (snackbar "Gelöscht · Rückgängig"), dragging (reorder)
|
||||
bg static mini scene gradient for the city's condition
|
||||
motion shared element (name + temp) into S01
|
||||
used on S02
|
||||
|
||||
SearchField + ResultList
|
||||
states empty (recents), typing (debounced 300ms, progress), results, no results ("Kein Ort gefunden"), error (retry)
|
||||
a11y SearchBar (M3), results announce count
|
||||
used on S03
|
||||
|
||||
TopAppBar
|
||||
variants transparent over scene (S01), surface (S02, S08)
|
||||
content menu/list button, city title (collapsed), settings icon (no account avatar)
|
||||
used on S01, S02, S08
|
||||
|
||||
PagerIndicator
|
||||
dots for saved cities, current one wide (expressive pill); swipe between cities
|
||||
used on S01
|
||||
|
||||
Settings rows
|
||||
variants radio group, switch, link row, section header
|
||||
states default, disabled (with reason text), focused
|
||||
used on S08, S09
|
||||
|
||||
Snackbar, Dialog (permission rationale), Shimmer placeholder: M3 standard with tokens.
|
||||
```
|
||||
|
||||
## Widgets (Glance)
|
||||
|
||||
```
|
||||
Widget shell
|
||||
radius widget (24dp, follows system launcher radius on Android 12+)
|
||||
styles dynamic_color (surface-container from wallpaper), condition (pre-rendered scene bitmap + on-scene text), transparent (text with shadow)
|
||||
states loading ("…"), filled, stale (small clock icon), no location ("Ort wählen"), error (tap to retry)
|
||||
tap opens S01 for that city; refresh icon only on ≥ 4×2
|
||||
|
||||
NowWidget 2×1: icon + temp | 2×2: + city, condition, H/T
|
||||
HourlyWidget 4×2: now block + 6 hours (icon, temp, time)
|
||||
DailyWidget 4×2: now + 5 days | 4×3: + range bars
|
||||
FullWidget 4×4: now, 5 hours, 5 days, alert line if active
|
||||
GlanceWidget 4×1: "Fr., 17. Okt. · ☀ 14°" one line, alert replaces date (two lines: event, agency)
|
||||
|
||||
type display-widget 44sp for temps, label for times; size by SizeMode.Responsive breakpoints
|
||||
a11y contentDescription on root summarising the widget
|
||||
config S12-config activity: location picker, style, live preview, "Fertig"
|
||||
```
|
||||
|
||||
## Screens → Komponenten (Status)
|
||||
|
||||
| Screen | Komponenten |
|
||||
| --- | --- |
|
||||
| S01 | WeatherScene, TopAppBar, HeroBlock, AlertBanner, BriefCard, HourlyStrip, DailyList, MetricCard×n, PagerIndicator |
|
||||
| S02 | TopAppBar, CityRow, SearchField |
|
||||
| S03 | SearchField, ResultList |
|
||||
| S04/S05 | HourlyStrip, chart, MetricCard expanded |
|
||||
| S06 | RadarMap |
|
||||
| S08/S09 | Settings rows |
|
||||
| S10 | MetricCard (dragging/placeholder) |
|
||||
| S11 | AlertBanner expanded, text |
|
||||
| S12 | Widgets |
|
||||
|
||||
## Abgleich mit Screenshots (ref-01..04), überschreibt Abweichungen oben
|
||||
|
||||
Gemessen an `replica/screens/ref-*.png`. Das Layout-Muster übernehmen wir, die Grafik zeichnen wir selbst.
|
||||
|
||||
- **HeroBlock:** zentrierte Spalte. Oben die Wetterlage (22sp), darunter die Temperatur sehr groß und **fett** (112sp, 700, nicht dünn) mit dem Wettericon direkt rechts daneben, etwa 40 % der Ziffernhöhe, oben ausgerichtet. Dann „Gefühlt 19°“ und „Höchstwert 22° · Tiefstwert 14°“. Am Tag ist der Text dunkel auf hellem Himmel, nachts weiß. Es gibt keinen Scrim, die hellen Szenen sind entsprechend hell gewählt.
|
||||
- **Szene:** ruhiger, weicher Verlauf mit großen, unscharfen, langsam treibenden Formen (Wolkenschemen, Hügel). Keine fotorealistische Darstellung. Bei Niederschlag fallen dezente Partikel.
|
||||
- **Karten-Container:** deckend (`card` bzw. `dark-card`, warm getöntes Neutral), Radius 28, Padding 20. Die Kopfzeile besteht aus einem Outline-Icon und dem Titel (card-title). Zwischen den Karten 12 dp Abstand, nach außen 16 dp Rand.
|
||||
- **HourlyStrip:** Die Spalten zeigen Temperatur, darunter Icon, darunter Niederschlag in % (`precip-text`, klein), darunter die Uhrzeit. Die aktuelle Stunde trägt einen **Cookie-Chip** (`now-chip`) hinter der Temperatur, das Label heißt „Jetzt“.
|
||||
- **DailyList:** **horizontal** scrollende Reihe vertikaler **Kapseln** (`pill`, Radius 24, ca. 56×190 dp). In jeder Kapsel stehen Höchstwert, Tiefstwert (muted), Icon, Niederschlag in % und Tag plus Datum. Ein Tipp auf die Kapsel öffnet S04.
|
||||
- **DayDetail (S04, bestätigt):** oben eine Kapselreihe, die gewählte Kapsel ist hervorgehoben (`pill-selected`). Darunter Datum, Ort, „24°/14°“ (day-range 56sp) mit Icon, Wetterlage, die stündliche Vorhersage für diesen Tag und darunter dieselben Detailkarten.
|
||||
- **Detailkarten:** Raster mit 2 Spalten aus quadratischen Kacheln, **Formen statt einheitlicher Karten**:
|
||||
- Wind: Kreis, darin ein abgerundetes Dreieck als Blob (`fill-*`), gedreht in die Windrichtung, mittig Wert und Einheit, darunter „Aus NW“
|
||||
- UV: Cookie mit 12 Wellen, Wert und Stufe, Punkte auf einem unteren Bogen, der aktive Punkt farbig
|
||||
- Sichtweite: Kreis mit gewellter Innenfläche (`fill-tertiary`), Wert und Einheit
|
||||
- Luftfeuchte: Rechteck, Füllstand mit gewellter Oberkante, Höhe = %, unten ein runder Chip mit dem Taupunkt
|
||||
- Luftdruck: Kreis mit 270°-Bogenanzeige, Wert groß, Einheit darunter
|
||||
- Sonne: Rechteck mit gefülltem Sinus-Hügel über der Horizontlinie, der Nachtteil dunkler, Sonnenpunkt auf der Kurve, Aufgang und Untergang als Zeiten
|
||||
- Luftqualität: Rechteck mit Verlaufsbalken und Knopf, Wert, Einstufung („Gute Luftqualität“); ohne Daten wird „– –“ gezeigt
|
||||
- **NowcastCard (neu):** „Regen ab 09:15“ als Titel, Untertitel „Leichter Regen von 09:15 bis 10:45“. Ein Punkt-Balkendiagramm zeigt die nächsten 2–3 Stunden in 15-Minuten-Schritten, die Y-Achse in drei Stufen (leicht, mäßig, stark). Ein Marker-Chip am Beginn, die X-Achse mit Uhrzeiten. Die Karte erscheint nur, wenn in den nächsten 3 Stunden Niederschlag kommt. Daten: Open-Meteo `minutely_15`.
|
||||
- **BriefCard:** wie eine normale Karte mit Icon, Titel „Kurz gesagt“ und einem Chevron zum Auf- und Zuklappen. Darunter 2–3 Sätze Text in body.
|
||||
- **Vorschau eines Orts (S15, neu):** Ein gesuchter Ort öffnet die volle Ansicht mit einem Button „Speichern“ oben rechts (gefüllter Pill-Button, Tonal).
|
||||
- **Abweichungen, die bewusst eigen bleiben:** eigene Icons und Szenen, ein eigener Akzent erst in `/replica-brand`, eigene Texte. Die Kartenformen sind M3-Expressive-Standardformen (`MaterialShapes`: Cookie, Circle, Triangle, Wavy), damit ist nichts vom Original nachgezeichnet.
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tokens.json -> Tokens.kt (Compose). Re-run after every token change."""
|
||||
import json, re, sys, pathlib
|
||||
src = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "tokens.json")
|
||||
out = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else src.with_name("Tokens.kt"))
|
||||
t = json.loads(src.read_text())
|
||||
camel = lambda s: re.sub(r"-(\w)", lambda m: m.group(1).upper(), s)
|
||||
L = ["// Generated from tokens.json by gen_theme.py. Do not edit by hand.",
|
||||
"package app.wetter.ui.theme", "",
|
||||
"import androidx.compose.ui.graphics.Color",
|
||||
"import androidx.compose.ui.text.TextStyle",
|
||||
"import androidx.compose.ui.text.font.FontWeight",
|
||||
"import androidx.compose.ui.unit.dp",
|
||||
"import androidx.compose.ui.unit.sp", "",
|
||||
"object WColor {"]
|
||||
L += [f" val {camel(k)} = Color(0xFF{v.lstrip('#').upper()})" for k, v in t["color"].items()]
|
||||
L += ["}", "", "object WType {"]
|
||||
for k, v in t["type"].items():
|
||||
L.append(f" val {camel(k)} = TextStyle(fontSize = {v['size']}.sp, lineHeight = {v['line']}.sp, fontWeight = FontWeight.W{v['weight']})")
|
||||
L += ["}", "", "object WSpace {"]
|
||||
L += [f" val s{v} = {v}.dp" for v in t["space"]]
|
||||
L += ["}", "", "object WRadius {"]
|
||||
L += [f" val {camel(k)} = {v}.dp" for k, v in t["radius"].items()]
|
||||
L += ["}", ""]
|
||||
out.write_text("\n".join(L))
|
||||
print("wrote", out)
|
||||
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"_about": "Wetter-Klon tokens. Roles measured by eye from replica/screens/ref-01..04 (Pixel Weather, 2024-25). Values are own approximations, no brand colour copied. Names stay, values change in /replica-brand. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/design/tokens.json",
|
||||
"color": {
|
||||
"bg": "#f8f9ff",
|
||||
"surface": "#ffffff",
|
||||
"surface-container": "#eceef4",
|
||||
"surface-container-high": "#e1e3ea",
|
||||
"border": "#c3c6cf",
|
||||
"border-input": "#73777f",
|
||||
"text": "#191c20",
|
||||
"text-muted": "#43474e",
|
||||
"accent": "#2a5ea8",
|
||||
"on-accent": "#ffffff",
|
||||
"accent-container": "#d6e3ff",
|
||||
"on-accent-container": "#001b3e",
|
||||
"danger": "#ba1a1a",
|
||||
"warning": "#8a5100",
|
||||
"success": "#2e6b30",
|
||||
"dark-bg": "#111318",
|
||||
"dark-surface": "#1d2024",
|
||||
"dark-surface-container": "#272a2f",
|
||||
"dark-border": "#43474e",
|
||||
"dark-border-input": "#8d9199",
|
||||
"dark-text": "#e2e2e9",
|
||||
"dark-text-muted": "#c3c6cf",
|
||||
"dark-accent": "#a9c7ff",
|
||||
"dark-on-accent": "#003063",
|
||||
"dark-danger": "#ffb4ab",
|
||||
"dark-warning": "#ffb870",
|
||||
"scene-clear-day-top": "#7cc4ef",
|
||||
"scene-clear-day-bottom": "#bfe3f7",
|
||||
"scene-clear-night-top": "#3d4f9a",
|
||||
"scene-clear-night-bottom": "#5b4f8f",
|
||||
"scene-cloudy-day-top": "#c9cdd2",
|
||||
"scene-cloudy-day-bottom": "#dfe2e5",
|
||||
"scene-rain-top": "#2e3d4f",
|
||||
"scene-rain-bottom": "#5a6b7d",
|
||||
"scene-snow-top": "#7c8ea3",
|
||||
"scene-snow-bottom": "#d4dde6",
|
||||
"scene-storm-top": "#1f1a33",
|
||||
"scene-storm-bottom": "#4a3f63",
|
||||
"scene-fog-top": "#8a929b",
|
||||
"scene-fog-bottom": "#c4c9ce",
|
||||
"on-scene": "#ffffff",
|
||||
"on-scene-muted": "#e8edf3",
|
||||
"on-scene-dark": "#14181d",
|
||||
"scene-card": "#2a3644",
|
||||
"scene-card-light": "#f2f5f8",
|
||||
"alert-minor": "#fff2b8",
|
||||
"on-alert-minor": "#3d2f00",
|
||||
"alert-severe": "#ffdbc8",
|
||||
"on-alert-severe": "#4a1a00",
|
||||
"alert-extreme": "#93000a",
|
||||
"on-alert-extreme": "#ffffff",
|
||||
"aqi-good": "#2e7d32",
|
||||
"aqi-moderate": "#a06a00",
|
||||
"aqi-bad": "#c62828",
|
||||
"uv-high": "#b34700",
|
||||
"card": "#fdf8f3",
|
||||
"on-card": "#1f1b17",
|
||||
"on-card-muted": "#5a524b",
|
||||
"dark-card": "#1f1714",
|
||||
"dark-on-card": "#ede0db",
|
||||
"dark-on-card-muted": "#c4b6b0",
|
||||
"fill-primary": "#d3e3fd",
|
||||
"on-fill-primary": "#0b2a55",
|
||||
"fill-secondary": "#e9ddff",
|
||||
"on-fill-secondary": "#2a1a4d",
|
||||
"fill-tertiary": "#c8e6c2",
|
||||
"on-fill-tertiary": "#0f3a12",
|
||||
"dark-fill-primary": "#a8c7fa",
|
||||
"dark-on-fill-primary": "#062e6f",
|
||||
"dark-fill-tertiary": "#2d5a27",
|
||||
"dark-on-fill-tertiary": "#b7f0ac",
|
||||
"dark-fill-olive": "#3e4a1e",
|
||||
"pill": "#f1ebe5",
|
||||
"dark-pill": "#2b221e",
|
||||
"pill-selected": "#a8c7fa",
|
||||
"on-pill-selected": "#0b2a55",
|
||||
"now-chip": "#4e4038",
|
||||
"on-now-chip": "#ffffff",
|
||||
"precip-text": "#2a5ea8",
|
||||
"dark-precip-text": "#a8c7fa",
|
||||
"nowcast-dot": "#2e7d32",
|
||||
"icon-sun": "#ffb02e",
|
||||
"icon-sun-core": "#ff9a1f",
|
||||
"icon-moon": "#a9c4ff",
|
||||
"icon-cloud": "#f4f6f9",
|
||||
"icon-cloud-shade": "#cfd6df",
|
||||
"icon-cloud-dark": "#9aa5b3",
|
||||
"icon-drop": "#3d7bf0",
|
||||
"icon-snow": "#ffffff",
|
||||
"icon-snow-outline": "#9fb3cc",
|
||||
"icon-bolt": "#ffc93c",
|
||||
"icon-fog": "#c3cad3",
|
||||
"scene-glow-sun": "#fff3c4",
|
||||
"scene-cloud": "#ffffff",
|
||||
"scene-hill": "#9fc5e6",
|
||||
"dark-scene-hill": "#2f3f78",
|
||||
"range-cold": "#6fa8ff",
|
||||
"range-warm": "#ffb02e",
|
||||
"aqi-bar-1": "#4fd1c5",
|
||||
"aqi-bar-2": "#9ccc65",
|
||||
"aqi-bar-3": "#ffd54f",
|
||||
"aqi-bar-4": "#ff8a65",
|
||||
"aqi-bar-5": "#e53935",
|
||||
"aqi-bar-6": "#8e24aa",
|
||||
"uv-dot-low": "#5ccf8a",
|
||||
"uv-dot-off": "#6b5a52"
|
||||
},
|
||||
"pairs": [
|
||||
[
|
||||
"text",
|
||||
"bg"
|
||||
],
|
||||
[
|
||||
"text",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"text",
|
||||
"surface-container"
|
||||
],
|
||||
[
|
||||
"text",
|
||||
"surface-container-high"
|
||||
],
|
||||
[
|
||||
"text-muted",
|
||||
"bg"
|
||||
],
|
||||
[
|
||||
"text-muted",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"text-muted",
|
||||
"surface-container"
|
||||
],
|
||||
[
|
||||
"on-accent",
|
||||
"accent"
|
||||
],
|
||||
[
|
||||
"on-accent-container",
|
||||
"accent-container"
|
||||
],
|
||||
[
|
||||
"accent",
|
||||
"bg"
|
||||
],
|
||||
[
|
||||
"accent",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"danger",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"warning",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"success",
|
||||
"surface"
|
||||
],
|
||||
[
|
||||
"border-input",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"accent",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"dark-text",
|
||||
"dark-bg"
|
||||
],
|
||||
[
|
||||
"dark-text",
|
||||
"dark-surface"
|
||||
],
|
||||
[
|
||||
"dark-text",
|
||||
"dark-surface-container"
|
||||
],
|
||||
[
|
||||
"dark-text-muted",
|
||||
"dark-bg"
|
||||
],
|
||||
[
|
||||
"dark-text-muted",
|
||||
"dark-surface"
|
||||
],
|
||||
[
|
||||
"dark-text-muted",
|
||||
"dark-surface-container"
|
||||
],
|
||||
[
|
||||
"dark-on-accent",
|
||||
"dark-accent"
|
||||
],
|
||||
[
|
||||
"dark-accent",
|
||||
"dark-surface"
|
||||
],
|
||||
[
|
||||
"dark-danger",
|
||||
"dark-surface"
|
||||
],
|
||||
[
|
||||
"dark-warning",
|
||||
"dark-surface"
|
||||
],
|
||||
[
|
||||
"dark-border-input",
|
||||
"dark-surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-clear-night-top"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-clear-night-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-rain-top"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-rain-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-storm-top"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-storm-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-clear-day-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-cloudy-day-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-snow-top"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-snow-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-fog-top"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-fog-bottom"
|
||||
],
|
||||
[
|
||||
"on-scene",
|
||||
"scene-card"
|
||||
],
|
||||
[
|
||||
"on-scene-muted",
|
||||
"scene-card"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-card-light"
|
||||
],
|
||||
[
|
||||
"on-alert-minor",
|
||||
"alert-minor"
|
||||
],
|
||||
[
|
||||
"on-alert-severe",
|
||||
"alert-severe"
|
||||
],
|
||||
[
|
||||
"on-alert-extreme",
|
||||
"alert-extreme"
|
||||
],
|
||||
[
|
||||
"aqi-good",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"aqi-moderate",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"aqi-bad",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"uv-high",
|
||||
"surface",
|
||||
"ui"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-clear-day-top"
|
||||
],
|
||||
[
|
||||
"on-scene-dark",
|
||||
"scene-cloudy-day-top"
|
||||
],
|
||||
[
|
||||
"on-card",
|
||||
"card"
|
||||
],
|
||||
[
|
||||
"on-card-muted",
|
||||
"card"
|
||||
],
|
||||
[
|
||||
"dark-on-card",
|
||||
"dark-card"
|
||||
],
|
||||
[
|
||||
"dark-on-card-muted",
|
||||
"dark-card"
|
||||
],
|
||||
[
|
||||
"on-fill-primary",
|
||||
"fill-primary"
|
||||
],
|
||||
[
|
||||
"on-fill-secondary",
|
||||
"fill-secondary"
|
||||
],
|
||||
[
|
||||
"on-fill-tertiary",
|
||||
"fill-tertiary"
|
||||
],
|
||||
[
|
||||
"dark-on-fill-primary",
|
||||
"dark-fill-primary"
|
||||
],
|
||||
[
|
||||
"dark-on-fill-tertiary",
|
||||
"dark-fill-tertiary"
|
||||
],
|
||||
[
|
||||
"dark-on-card",
|
||||
"dark-fill-olive"
|
||||
],
|
||||
[
|
||||
"on-card",
|
||||
"pill"
|
||||
],
|
||||
[
|
||||
"dark-on-card",
|
||||
"dark-pill"
|
||||
],
|
||||
[
|
||||
"on-pill-selected",
|
||||
"pill-selected"
|
||||
],
|
||||
[
|
||||
"on-now-chip",
|
||||
"now-chip"
|
||||
],
|
||||
[
|
||||
"precip-text",
|
||||
"card"
|
||||
],
|
||||
[
|
||||
"precip-text",
|
||||
"pill"
|
||||
],
|
||||
[
|
||||
"dark-precip-text",
|
||||
"dark-card"
|
||||
],
|
||||
[
|
||||
"dark-precip-text",
|
||||
"dark-pill"
|
||||
],
|
||||
[
|
||||
"nowcast-dot",
|
||||
"card",
|
||||
"ui"
|
||||
]
|
||||
],
|
||||
"font": {
|
||||
"sans": "Roboto Flex (OFL), system default fallback",
|
||||
"display": "Roboto Flex, weight 700-800, tight tracking (-2%) for the hero temperature",
|
||||
"mono": "Roboto Mono"
|
||||
},
|
||||
"type": {
|
||||
"label-sm": {
|
||||
"size": 11,
|
||||
"line": 16,
|
||||
"weight": 500
|
||||
},
|
||||
"label": {
|
||||
"size": 12,
|
||||
"line": 16,
|
||||
"weight": 500
|
||||
},
|
||||
"body-sm": {
|
||||
"size": 14,
|
||||
"line": 20,
|
||||
"weight": 400
|
||||
},
|
||||
"body": {
|
||||
"size": 16,
|
||||
"line": 24,
|
||||
"weight": 400
|
||||
},
|
||||
"title": {
|
||||
"size": 18,
|
||||
"line": 24,
|
||||
"weight": 500
|
||||
},
|
||||
"headline": {
|
||||
"size": 24,
|
||||
"line": 32,
|
||||
"weight": 500
|
||||
},
|
||||
"metric": {
|
||||
"size": 32,
|
||||
"line": 40,
|
||||
"weight": 500
|
||||
},
|
||||
"display": {
|
||||
"size": 112,
|
||||
"line": 112,
|
||||
"weight": 700
|
||||
},
|
||||
"display-widget": {
|
||||
"size": 44,
|
||||
"line": 48,
|
||||
"weight": 400
|
||||
},
|
||||
"day-range": {
|
||||
"size": 56,
|
||||
"line": 60,
|
||||
"weight": 600
|
||||
},
|
||||
"hero-condition": {
|
||||
"size": 22,
|
||||
"line": 28,
|
||||
"weight": 400
|
||||
},
|
||||
"card-title": {
|
||||
"size": 16,
|
||||
"line": 24,
|
||||
"weight": 500
|
||||
}
|
||||
},
|
||||
"space": [
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
8,
|
||||
12,
|
||||
16,
|
||||
20,
|
||||
24,
|
||||
32,
|
||||
48
|
||||
],
|
||||
"radius": {
|
||||
"xs": 8,
|
||||
"sm": 12,
|
||||
"md": 16,
|
||||
"lg": 24,
|
||||
"xl": 28,
|
||||
"widget": 24,
|
||||
"pill": 999,
|
||||
"card": 28,
|
||||
"day-pill": 24
|
||||
},
|
||||
"shape": {
|
||||
"wind": "circle container, inner rounded-triangle blob rotated to wind direction",
|
||||
"uv": "scalloped 12-wave cookie, dots on a lower arc show the UV band",
|
||||
"visibility": "circle container, inner wavy-edged circle fill",
|
||||
"humidity": "rounded rect, wave-topped fill whose height = humidity",
|
||||
"pressure": "circle container, 270° arc gauge",
|
||||
"sun": "rounded rect, filled sine hill above a horizon line, night part darker",
|
||||
"aqi": "rounded rect, gradient bar with knob",
|
||||
"now-chip": "8-point soft cookie behind current-hour temp",
|
||||
"day": "vertical capsule per day (10-day row scrolls horizontally)"
|
||||
},
|
||||
"elevation": {
|
||||
"card": 0,
|
||||
"card-drag": 6,
|
||||
"sheet": 1,
|
||||
"fab": 3
|
||||
},
|
||||
"motion": {
|
||||
"spring-fast": "dampingRatio 0.9, stiffness 1400",
|
||||
"spring-default": "dampingRatio 0.8, stiffness 380",
|
||||
"spring-bouncy": "dampingRatio 0.6, stiffness 300",
|
||||
"scene-crossfade": "600ms",
|
||||
"fast": "120ms",
|
||||
"base": "250ms"
|
||||
},
|
||||
"layout": {
|
||||
"gutter": 16,
|
||||
"card-gap": 12,
|
||||
"grid-compact": 2,
|
||||
"grid-expanded": 4,
|
||||
"two-pane-from": "600dp",
|
||||
"hero-height": "~45% of viewport",
|
||||
"app-bar": 64,
|
||||
"hero": "centered column: condition, temp + inline icon, feels like, high · low",
|
||||
"card-padding": 20,
|
||||
"detail-grid": "2 columns, circles left / rects right, equal squares"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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
|
||||
|
@@ -0,0 +1,148 @@
|
||||
# Recon map: Google Weather / Pixel Weather (Android)
|
||||
|
||||
Scope: the whole app: home forecast, detail cards, locations, settings, precipitation map, alerts, notifications, widget, AI brief
|
||||
For: personal use, a weather app without Google services (project NoGooleApps)
|
||||
Date: 2026-10-17
|
||||
|
||||
## Sources
|
||||
|
||||
| # | source | URL | notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | app store listing | https://play.google.com/store/apps/details?id=com.google.android.apps.weather | page did not load via fetch; no screenshots saved |
|
||||
| 2 | help center | https://support.google.com/googlepixeltablet/answer/15266029?hl=en-GB | most complete feature/settings list |
|
||||
| 3 | review | https://9to5google.com/2024/10/30/pixel-weather-rolling-out/ | cards, reorder, per-city layout, notifications |
|
||||
| 4 | review | https://9to5google.com/2024/05/23/new-google-weather-android/ | redesign, NowCast |
|
||||
| 5 | news | https://9to5google.com/2025/01/30/pixel-weather-units-of-measurement-update/ | units settings |
|
||||
| 6 | news | https://www.notebookcheck.net/Google-is-quietly-killing-Android-s-weather-app-experience.1231441.0.html | 2026: non-Pixel weather redirected to Search |
|
||||
| 7 | news | https://www.techradar.com/phones/google-pixel-phones/the-pixel-9s-amazing-new-weather-app-is-now-heading-to-older-pixels | AI report on Pixel 9 |
|
||||
| 8 | news | https://9to5google.com/2021/05/17/pixel-launcher-weather-alerts/ | alert wording (type + issuing agency) |
|
||||
|
||||
| 9 | screenshots | replica/screens/ref-01..04.png | supplied by user (Android Authority / connect montages): hero, cards, day view, nowcast, light+dark. Reference only, never shipped |
|
||||
|
||||
## Core loop
|
||||
|
||||
Open the app and see at a glance what the weather is now, in the next hours and in the coming days for your location, plus whether something severe is coming.
|
||||
|
||||
## Screens
|
||||
|
||||
| ID | screen | route / how to reach | purpose | key components | states seen |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| S01 | Home / city forecast | app launch | current weather + all cards for one city | hero, alert banner, brief, nowcast card, hourly strip, 10-day capsules, shaped detail cards, animated background | loading, filled, alert active, no location permission, offline (guess) |
|
||||
| S02 | Saved locations | top-left / back from S01 | switch, add, delete cities | current-location row, city rows with temp/condition, search field, swipe-to-delete | only current, many, permission denied |
|
||||
| S03 | Location search | search on S02 | find a city | search input, result list, Add button | empty, results, no results |
|
||||
| S04 | Day detail | tap a day capsule in 10-day | one day: range, hourly, cards | capsule picker (selected), day-range, hourly strip, detail cards | filled (ref-01) |
|
||||
| S15 | Location preview | tap a search result | full forecast before saving | hero, cards, Save button | unsaved (ref-02) |
|
||||
| S05 | Card detail | tap a card (guess) | expanded metric (e.g. AQI, pollen, UV) | chart, explanation text | filled (guess) |
|
||||
| S06 | Weather map | map card on S01 | 6h precipitation nowcast | map, precip overlay, timeline scrubber, play | filled, region unavailable |
|
||||
| S07 | Profile menu | avatar top-right | entry to settings, theme | menu list | |
|
||||
| S08 | Settings | S07 -> Weather settings | units, vibrations, AI brief, notifications | radio lists, toggles | |
|
||||
| S09 | Units | S08 -> Temperature / Weather units | change units | radio groups | |
|
||||
| S10 | Rearrange mode | long-press a card on S01 | reorder cards | drag handles, drop placeholder | dragging |
|
||||
| S11 | Alert detail | tap alert banner | full warning text, agency, validity | text, source link | |
|
||||
| S12 | Homescreen widget | launcher | glance view | temp, icon, high/low, location | sizes |
|
||||
| S13 | Notifications | system | rain soon, tomorrow forecast, severe | notification | |
|
||||
| S14 | Onboarding / permissions | first launch (guess) | ask location + notifications | permission prompts | granted, denied |
|
||||
|
||||
## Flows
|
||||
|
||||
```
|
||||
F01 Check weather at current location
|
||||
S14 (first time) -> S01
|
||||
happy path clicks: 0
|
||||
edge: location denied, GPS off, offline with cached data, stale data
|
||||
|
||||
F02 Add a city
|
||||
S01 -> S02 -> S03 -> S15 preview -> Save -> S01 (new city)
|
||||
happy path clicks: 4 (open list, tap search, type, tap result/Add)
|
||||
edge: ambiguous names, postal code, no results, duplicate city
|
||||
|
||||
F03 Delete a city
|
||||
S02 swipe left
|
||||
happy path clicks: 1 swipe
|
||||
edge: undo, deleting the current-location row (not allowed)
|
||||
|
||||
F04 Switch between cities
|
||||
S01 -> S02 -> tap city (or swipe pager on S01, guess)
|
||||
happy path clicks: 2
|
||||
|
||||
F05 Watch rain coming in
|
||||
S01 -> map card -> S06 -> scrub / play
|
||||
happy path clicks: 2
|
||||
edge: region without nowcast, tiles fail to load
|
||||
|
||||
F06 Change units
|
||||
S01 -> S07 -> S08 -> S09 -> pick
|
||||
happy path clicks: 4
|
||||
|
||||
F07 Reorder cards
|
||||
S01 long-press -> S10 drag -> drop
|
||||
edge: cannot move above brief/hourly, layout per city
|
||||
|
||||
F08 React to a severe warning
|
||||
S13 notification -> S01 alert banner -> S11
|
||||
happy path clicks: 2
|
||||
edge: multiple alerts, expired alert, AI brief hidden while alert active
|
||||
|
||||
F09 Enable rain notifications
|
||||
S08 -> notifications toggle -> per-city toggle
|
||||
edge: notification permission denied, battery optimisation kills worker
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| component | variants | states | used on |
|
||||
| --- | --- | --- | --- |
|
||||
| Hero block | city name, big temp, condition, feels-like, H/L | loading, stale | S01 |
|
||||
| Alert banner | minor, moderate, severe, extreme | collapsed, expanded | S01, S11 |
|
||||
| Brief card | text summary | loading, hidden (alert) | S01 |
|
||||
| Hourly strip | temp + icon + precip % per hour | scroll | S01, S04 |
|
||||
| Day row | icon, precip %, low-high bar | pressed | S01 |
|
||||
| Metric card | small square, wide | default, dragging, pressed | S01, S10 |
|
||||
| Gauge / dial | UV, AQI, pressure, wind compass | | metric cards |
|
||||
| Sun arc | sunrise/sunset arc | day, night | sunrise card |
|
||||
| City row | current location, saved | swiping, pressed | S02 |
|
||||
| Search field | | empty, typing, results | S03 |
|
||||
| Map + scrubber | | playing, paused, loading tiles | S06 |
|
||||
| Settings rows | radio, toggle, link | disabled | S08, S09 |
|
||||
| Animated background | per condition, day/night | reduced motion | S01 |
|
||||
| Top app bar | with avatar/menu | | S01, S02 |
|
||||
| Widget | small, medium | | S12 |
|
||||
|
||||
## Inferred data model
|
||||
|
||||
```
|
||||
Location id, name, admin_area, country, lat, lon, timezone, is_current, sort_order
|
||||
evidence: S02, S03, help "Add a location" confidence: high
|
||||
CardLayout location_id, card_type, position
|
||||
evidence: 9to5google "customisations unique per city" confidence: high
|
||||
Forecast location_id, fetched_at, current{temp, feels_like, code, humidity, wind_speed,
|
||||
wind_dir, gusts, pressure, visibility, uv, dew_point, is_day},
|
||||
hourly[], daily[]{date, code, t_min, t_max, precip_prob, precip_sum, sunrise, sunset, uv_max}
|
||||
evidence: S01 cards confidence: high (cache, not user data)
|
||||
AirQuality location_id, aqi, pm2_5, pm10, o3, no2, pollen{grass, birch, ...}
|
||||
evidence: AQI/pollen cards confidence: high
|
||||
Alert id, location_id, event, severity, headline, description, agency, onset, expires
|
||||
evidence: help + alert banner, At a Glance two-line alert confidence: medium
|
||||
Settings temp_unit, precip_unit, wind_unit, pressure_unit, visibility_unit, theme,
|
||||
vibrations, brief_enabled, notif_tomorrow, notif_severe
|
||||
evidence: S08, S09 confidence: high
|
||||
NotificationPref location_id, rain_alerts confidence: medium
|
||||
```
|
||||
|
||||
Relationships: Location 1-n CardLayout, Location 1-1 Forecast (cached), Location 1-1 AirQuality, Location 1-n Alert, Location 1-1 NotificationPref. No user accounts needed (local only).
|
||||
|
||||
## Feature matrix
|
||||
|
||||
See `features.csv`. Must: 18, should: 14, could: 7, skip: 4 (design + widgets raised to must).
|
||||
|
||||
## Out of scope (cannot or should not be cloned)
|
||||
|
||||
- Google's own forecast and nowcasting models (MetNet / NowCast): proprietary. Replace with Open-Meteo (forecast, AQI, pollen, geocoding), DWD / MeteoAlarm (alerts), RainViewer or DWD radar (map).
|
||||
- Gemini-based Weather Brief and personal insights from Calendar/Gmail: Google AI and Google data. A rule- or template-based summary is the replacement, or optionally a local LLM.
|
||||
- Location sync via Google account: deliberately left out.
|
||||
- At a Glance / Pixel launcher integration: a system feature.
|
||||
- Google's illustrations, icons, animations and the "Froggy" art: original assets have to be made from scratch.
|
||||
|
||||
## Size
|
||||
|
||||
Screens 15, flows 9, entities 7. Hard parts: (1) the precipitation map with timeline (tile sources, licences, animation), (2) reliable background notifications/alerts without Play Services (WorkManager, Doze), (3) polished animated backgrounds + homescreen widget. Size: M (a few weeks) for a native Android app (Kotlin/Compose).
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Local SQLite schema (Room). No server, no accounts: all data is on the device.
|
||||
-- Times are INTEGER epoch milliseconds UTC; local display uses Location.timezone.
|
||||
|
||||
CREATE TABLE locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
admin_area TEXT,
|
||||
country_code TEXT NOT NULL,
|
||||
lat REAL NOT NULL CHECK (lat BETWEEN -90 AND 90),
|
||||
lon REAL NOT NULL CHECK (lon BETWEEN -180 AND 180),
|
||||
timezone TEXT NOT NULL, -- IANA, from geocoding
|
||||
is_current INTEGER NOT NULL DEFAULT 0 CHECK (is_current IN (0,1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX locations_one_current ON locations(is_current) WHERE is_current = 1;
|
||||
CREATE UNIQUE INDEX locations_no_dupe ON locations(round(lat,2), round(lon,2)) WHERE is_current = 0;
|
||||
CREATE INDEX locations_sort ON locations(sort_order);
|
||||
|
||||
CREATE TABLE card_layout (
|
||||
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||
card_type TEXT NOT NULL CHECK (card_type IN
|
||||
('precip','wind','uv','humidity','pressure','visibility','sun','aqi','pollen','map')),
|
||||
position INTEGER NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0,1)),
|
||||
PRIMARY KEY (location_id, card_type)
|
||||
);
|
||||
CREATE INDEX card_layout_order ON card_layout(location_id, position);
|
||||
|
||||
-- Forecast cache: raw provider JSON + parsed columns the UI and widgets read fast.
|
||||
CREATE TABLE forecast_cache (
|
||||
location_id INTEGER PRIMARY KEY REFERENCES locations(id) ON DELETE CASCADE,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
current_json TEXT NOT NULL,
|
||||
hourly_json TEXT NOT NULL, -- 48h
|
||||
daily_json TEXT NOT NULL, -- 10 days
|
||||
air_json TEXT, -- AQI + pollen
|
||||
source TEXT NOT NULL DEFAULT 'open-meteo'
|
||||
);
|
||||
CREATE INDEX forecast_fetched ON forecast_cache(fetched_at);
|
||||
|
||||
CREATE TABLE alerts (
|
||||
id TEXT PRIMARY KEY, -- provider identifier, dedupe key
|
||||
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||
event TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('minor','moderate','severe','extreme')),
|
||||
headline TEXT NOT NULL,
|
||||
description TEXT,
|
||||
instruction TEXT,
|
||||
agency TEXT NOT NULL,
|
||||
onset INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL CHECK (expires > onset),
|
||||
notified INTEGER NOT NULL DEFAULT 0 CHECK (notified IN (0,1))
|
||||
);
|
||||
CREATE INDEX alerts_loc_active ON alerts(location_id, expires);
|
||||
|
||||
CREATE TABLE widget_config (
|
||||
app_widget_id INTEGER PRIMARY KEY, -- Android appWidgetId
|
||||
location_id INTEGER REFERENCES locations(id) ON DELETE SET NULL, -- NULL = follow current location
|
||||
style TEXT NOT NULL DEFAULT 'auto' CHECK (style IN ('auto','dynamic_color','condition','transparent')),
|
||||
content TEXT NOT NULL DEFAULT 'auto' CHECK (content IN ('auto','now','hourly','daily'))
|
||||
);
|
||||
CREATE INDEX widget_loc ON widget_config(location_id);
|
||||
|
||||
CREATE TABLE notification_pref (
|
||||
location_id INTEGER PRIMARY KEY REFERENCES locations(id) ON DELETE CASCADE,
|
||||
rain_alerts INTEGER NOT NULL DEFAULT 0,
|
||||
severe INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Settings live in Jetpack DataStore (typed key-value), not here:
|
||||
-- temp_unit, precip_unit, wind_unit, pressure_unit, visibility_unit,
|
||||
-- theme (system|light|dark), dynamic_color, animations, vibrations,
|
||||
-- brief_enabled, notif_tomorrow_time, data_source.
|
||||
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 866 KiB |