commit e02f5aa18dda7560fd6b272a89143038d0af15eb Author: Louis Date: Sat Oct 17 13:52:42 2026 +0200 replica: recon, architecture, design tokens, build log, reference and clone screenshots Co-Authored-By: Claude Opus 5.5 diff --git a/replica/architecture.md b/replica/architecture.md new file mode 100644 index 0000000..eb72a42 --- /dev/null +++ b/replica/architecture.md @@ -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 | 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 | 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 | 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`. diff --git a/replica/build-log.md b/replica/build-log.md new file mode 100644 index 0000000..3fb85ce --- /dev/null +++ b/replica/build-log.md @@ -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. diff --git a/replica/clone-screens/S01-clear-day.png b/replica/clone-screens/S01-clear-day.png new file mode 100644 index 0000000..42576cf Binary files /dev/null and b/replica/clone-screens/S01-clear-day.png differ diff --git a/replica/clone-screens/S01-clear-night-dark.png b/replica/clone-screens/S01-clear-night-dark.png new file mode 100644 index 0000000..275f416 Binary files /dev/null and b/replica/clone-screens/S01-clear-night-dark.png differ diff --git a/replica/clone-screens/S01-full-dark.png b/replica/clone-screens/S01-full-dark.png new file mode 100644 index 0000000..dd110fb Binary files /dev/null and b/replica/clone-screens/S01-full-dark.png differ diff --git a/replica/clone-screens/S01-full-light.png b/replica/clone-screens/S01-full-light.png new file mode 100644 index 0000000..5a5045c Binary files /dev/null and b/replica/clone-screens/S01-full-light.png differ diff --git a/replica/clone-screens/S01-rain-nowcast.png b/replica/clone-screens/S01-rain-nowcast.png new file mode 100644 index 0000000..8ff43bc Binary files /dev/null and b/replica/clone-screens/S01-rain-nowcast.png differ diff --git a/replica/clone-screens/S01-real-light.png b/replica/clone-screens/S01-real-light.png new file mode 100644 index 0000000..57c8617 Binary files /dev/null and b/replica/clone-screens/S01-real-light.png differ diff --git a/replica/clone-screens/S01-snow.png b/replica/clone-screens/S01-snow.png new file mode 100644 index 0000000..82b492c Binary files /dev/null and b/replica/clone-screens/S01-snow.png differ diff --git a/replica/clone-screens/S01-storm-dark.png b/replica/clone-screens/S01-storm-dark.png new file mode 100644 index 0000000..dbc36b3 Binary files /dev/null and b/replica/clone-screens/S01-storm-dark.png differ diff --git a/replica/clone-screens/S01-tablet.png b/replica/clone-screens/S01-tablet.png new file mode 100644 index 0000000..bfdc70a Binary files /dev/null and b/replica/clone-screens/S01-tablet.png differ diff --git a/replica/clone-screens/S04-day-dark.png b/replica/clone-screens/S04-day-dark.png new file mode 100644 index 0000000..2cf4770 Binary files /dev/null and b/replica/clone-screens/S04-day-dark.png differ diff --git a/replica/clone-screens/S08-settings.png b/replica/clone-screens/S08-settings.png new file mode 100644 index 0000000..5e78758 Binary files /dev/null and b/replica/clone-screens/S08-settings.png differ diff --git a/replica/clone-screens/S15-preview.png b/replica/clone-screens/S15-preview.png new file mode 100644 index 0000000..9ed4073 Binary files /dev/null and b/replica/clone-screens/S15-preview.png differ diff --git a/replica/design/Tokens.kt b/replica/design/Tokens.kt new file mode 100644 index 0000000..3eaf9db --- /dev/null +++ b/replica/design/Tokens.kt @@ -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 +} diff --git a/replica/design/components.md b/replica/design/components.md new file mode 100644 index 0000000..bf42803 --- /dev/null +++ b/replica/design/components.md @@ -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. diff --git a/replica/design/gen_theme.py b/replica/design/gen_theme.py new file mode 100644 index 0000000..a27722c --- /dev/null +++ b/replica/design/gen_theme.py @@ -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) diff --git a/replica/design/tokens.json b/replica/design/tokens.json new file mode 100644 index 0000000..4fab572 --- /dev/null +++ b/replica/design/tokens.json @@ -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" + } +} \ No newline at end of file diff --git a/replica/features.csv b/replica/features.csv new file mode 100644 index 0000000..0bb7c85 --- /dev/null +++ b/replica/features.csv @@ -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 diff --git a/replica/recon.md b/replica/recon.md new file mode 100644 index 0000000..3eb4cd9 --- /dev/null +++ b/replica/recon.md @@ -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). diff --git a/replica/schema.sql b/replica/schema.sql new file mode 100644 index 0000000..4213aed --- /dev/null +++ b/replica/schema.sql @@ -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. diff --git a/replica/screens/ref-01-cards-hero-dayview-dark.png b/replica/screens/ref-01-cards-hero-dayview-dark.png new file mode 100644 index 0000000..295509a Binary files /dev/null and b/replica/screens/ref-01-cards-hero-dayview-dark.png differ diff --git a/replica/screens/ref-02-light-ai-report-nowcast.png b/replica/screens/ref-02-light-ai-report-nowcast.png new file mode 100644 index 0000000..bf8ffe4 Binary files /dev/null and b/replica/screens/ref-02-light-ai-report-nowcast.png differ diff --git a/replica/screens/ref-03-cloudy-light-cards.png b/replica/screens/ref-03-cloudy-light-cards.png new file mode 100644 index 0000000..36ca4b0 Binary files /dev/null and b/replica/screens/ref-03-cloudy-light-cards.png differ diff --git a/replica/screens/ref-04-cards-dark-vs-old-app.png b/replica/screens/ref-04-cards-dark-vs-old-app.png new file mode 100644 index 0000000..dabf4dd Binary files /dev/null and b/replica/screens/ref-04-cards-dark-vs-old-app.png differ