# GPS2Audio v2.7.28 — Map Style Apply Fix — Handoff **Version:** 2.7.28 (versionCode 134) **Basis:** v2.7.27 (versionCode 133) **Datum:** 2025-06-01 --- ## Ausgabe-Dateien | Datei | Beschreibung | |---|---| | `GPS2Audio_v2_7_28_map_style_apply_fix_universal_signed.apk` | Signierte universelle APK (57 MB) | | `GPS2Audio_v2_7_28_map_style_apply_fix_source.zip` | Quellcode-ZIP (485 KB) | | `GPS2Audio_v2_7_28_map_style_apply_fix_handoff.md` | Diese Datei | --- ## Root Cause: Warum UI-Auswahl gespeichert war, aber Karte visuell nicht wechselte ### Das Race-Condition-Problem `ExplorerScreenMapLibre` hat zwei asynchrone Initialisierungspfade, die miteinander in einem Race-Condition-Verhältnis standen: **Pfad 1 — AndroidView Factory (synchron beim ersten Composition-Frame):** 1. `layerState` startet mit Default `MapLayerState(baseStyle = MapBaseStyle.MODERN_2D)` 2. `AndroidView factory` liest `layerState.baseStyle` → **MODERN_2D** → lädt Positron-Style 3. Im Lade-Callback: `styleReady = true` **Pfad 2 — DataStore Restore (asynchron, kein fester Zeitpunkt):** 1. `mapStyleSettingsOrNull` wird via `collectAsState(initial = null)` beobachtet 2. Wenn echter DataStore-Wert ankommt: `LaunchedEffect(mapStyleSettingsOrNull)` setzt `layerState.baseStyle = OUTDOOR` (oder was auch immer gespeichert ist) 3. `styleHydrated = true` **Der Style-Change-Effekt (v2.7.27):** ```kotlin LaunchedEffect(layerState.baseStyle, mapStyleSettings.perspectiveTilt) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect // <-- HIER lag das Problem // ... ml.setStyle(...) } ``` **Häufigster Fehlerablauf:** 1. Composition startet → Factory beginnt Positron zu laden → `styleReady = false` 2. DataStore-Wert kommt an → `layerState.baseStyle` wechselt zu OUTDOOR 3. `LaunchedEffect(layerState.baseStyle=OUTDOOR)` feuert → Guard `!styleReady` → **return** (Factory noch nicht fertig) 4. Factory-Callback feuert → `styleReady = true` 5. **`LaunchedEffect` feuert NICHT erneut** — `layerState.baseStyle` hat sich seit Schritt 3 nicht geändert 6. **Ergebnis:** Karte zeigt Positron (MODERN_2D), UI zeigt OUTDOOR ✓ — visuell falsch Das Hauptproblem: `styleReady` war **kein Key** des LaunchedEffect, also triggerte das Werden von `styleReady=true` den Effekt nicht neu. --- ## Fix: effectiveBaseStyle-Ansatz mit appliedBaseStyle ### Lösung: `appliedBaseStyle` als Single Source of Truth Zwei neue State-Variablen: ```kotlin var appliedBaseStyle by remember { mutableStateOf(null) } var appliedPerspectiveTilt by remember { mutableStateOf(null) } ``` Diese verfolgen, welcher Stil **tatsächlich** via `ml.setStyle()` auf die Karte angewendet wurde — im Gegensatz zu `layerState.baseStyle`, das den **gewünschten** Stil hält. ### Geänderter LaunchedEffect (Style-Apply) ```kotlin // v2.7.28 — styleReady + mapLibreMap als zusätzliche Keys LaunchedEffect( layerState.baseStyle, mapStyleSettings.perspectiveTilt, styleReady, // NEU: Triggert Re-Fire wenn Factory-Callback abgeschlossen mapLibreMap // NEU: Triggert Re-Fire wenn Map initialisiert ) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect // NEU: Doppel-Apply-Schutz val currentTilt = if (3D-Stil) perspectiveTilt else 0 if (layerState.baseStyle == appliedBaseStyle && currentTilt == (appliedPerspectiveTilt ?: 0)) return@LaunchedEffect // ...setStyle... } ``` ### Doppel-Apply-Schutz (verhindert v2.7.25-Regression) Das bisherige Regressions-Problem: Wenn `styleReady` als Key verwendet wird, feuert der Effekt nach jedem `styleReady=true`-Übergang — auch wenn der Stil schon korrekt geladen ist. Das würde Overlays wegwischen. **Lösung:** `appliedBaseStyle` + `appliedPerspectiveTilt` merken sich, welcher Stil+Tilt tatsächlich angewendet wurde. Wenn `layerState.baseStyle == appliedBaseStyle` und Tilt unverändert → `return` ohne setStyle. ### Factory-Callback (initial) ```kotlin val initialTargetStyle = layerState.baseStyle val initialTargetTilt = if (3D-Stil) perspectiveTilt else 0 ml.setStyle(initialStyleBuilder) { loadedStyle -> // ... appliedBaseStyle = initialTargetStyle // NEU appliedPerspectiveTilt = initialTargetTilt // NEU styleReady = true overlayRenderNonce++ } ``` ### Korrekter Ablauf nach dem Fix **Szenario 1 — DataStore kommt NACH Factory-Callback:** 1. Factory lädt MODERN_2D → `appliedBaseStyle = MODERN_2D`, `styleReady = true` 2. DataStore → `layerState.baseStyle = OUTDOOR` 3. `LaunchedEffect(OUTDOOR, tilt, styleReady=true, map=ml)` feuert 4. Guard: `OUTDOOR != MODERN_2D (appliedBaseStyle)` → **setStyle(liberty)** → `appliedBaseStyle = OUTDOOR` ✓ **Szenario 2 — DataStore kommt BEVOR Factory-Callback:** 1. DataStore → `layerState.baseStyle = OUTDOOR` 2. `LaunchedEffect(OUTDOOR, ...)` feuert → `styleReady=false` → return 3. Factory-Callback → `appliedBaseStyle = MODERN_2D` (Factory lud MODERN_2D), `styleReady = true` 4. `LaunchedEffect` feuert erneut (weil `styleReady` jetzt Key ist und sich änderte) 5. Guard: `OUTDOOR != MODERN_2D` → **setStyle(liberty)** ✓ **Szenario 3 — Nutzer wechselt Stil manuell im Sheet:** 1. Sheet → `layerState.baseStyle = DARK` 2. `LaunchedEffect(DARK, ...)` feuert, `styleReady=true` 3. Guard: `DARK != OUTDOOR (appliedBaseStyle)` → **setStyle(dark)** → `appliedBaseStyle = DARK` ✓ **Szenario 4 — Kein Doppel-Apply nach Initial-Load:** 1. Factory lädt CITY_3D → `appliedBaseStyle = CITY_3D`, `styleReady = true` 2. `LaunchedEffect(CITY_3D, tilt=35, styleReady=true, ...)` feuert 3. Guard: `CITY_3D == CITY_3D` und `35 == 35` → **return** (kein Doppel-Apply) ✓ --- ## Overlays nach Stilwechsel Bestehende Overlay-Render-Mechanismen bleiben vollständig erhalten: - `overlayRenderNonce++` im `onStyleReady`-Callback (sowohl Factory als auch Style-Change-Effect) - `LaunchedEffect(routeLine, ..., overlayRenderNonce)` → zeichnet Tourlinie neu - `LaunchedEffect(waypointRenderKey, ..., overlayRenderNonce, routeRenderKey)` → zeichnet Waypoints, Radius, Track neu - `clearAllAnnotationsCaches(...)` vor `setStyle()` → Phantom-Layer-Schutz (wie v2.7.25) - `styleReady = false` vor setStyle → blockiert Overlay-Render während Style lädt Alle Overlays (Tourlinie, Waypoints, POIs, Radius, Planlinien) werden nach jedem Stilwechsel durch den Nonce-Mechanismus neu gerendert. --- ## Geänderte Dateien | Datei | Änderungen | |---|---| | `app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt` | Haupt-Fix: appliedBaseStyle, appliedPerspectiveTilt, LaunchedEffect-Keys | | `app/build.gradle.kts` | versionCode 133→134, versionName 2.7.27→2.7.28 | --- ## Testliste | Test | Erwartetes Ergebnis | |---|---| | Outdoor wählen, Sheet schließen | Karte wechselt sofort zu OpenFreeMap Liberty (grüner Outdoor-Stil) | | Dunkel wählen, Sheet schließen | Karte wechselt sofort zu Dark Matter | | Modern wählen, Sheet schließen | Karte wechselt zu hellem Positron-Stil | | 3D Stadt wählen | Karte lädt Positron + Gebäude-Extrusion | | Klassisch wählen | Karte lädt OSM-Raster-Tiles | | App neu starten nach Outdoor | Explorer öffnet direkt mit Outdoor-Stil (nicht Modern) | | Explorer schließen/öffnen nach Dunkel | Stil bleibt Dunkel | | Tourlinie nach Stilwechsel | Linie bleibt sichtbar (overlayRenderNonce) | | Waypoints nach Stilwechsel | Marker bleiben sichtbar | | Standort folgen bleibt aus | Kein unbeabsichtigtes Aktivieren des Follow-Modus | | Mehrfaches Wechseln | Kein Absturz, Karte folgt jedem Wechsel | | Tilt-Änderung bei 3D Stadt | Kamera-Pitch wird korrekt angepasst | --- ## Regressions-Schutz Alle Guards aus v2.7.17–v2.7.27 bleiben erhalten: - **v2.7.27** Kartenpräferenz-Persistenz: `styleHydrated`-Guard, vollständige Restore-Logik - **v2.7.26** Standort-Folgen-Fix: `followLocation`-Persistenz unberührt - **v2.7.25** Initial-Render-Fix: `overlayRenderNonce`, `savedRouteLine`-Guard - **v2.7.24** UX Polish: Alle UI-Elemente unberührt - **v2.7.23** Tourverlauf übernehmen: RoutePlannerSheet unberührt - **v2.7.22** Saved Route Render: `routeRenderKey`, `activeTourRoute`-Keys - **v2.7.21** Waypoint Preservation Guard: `waypointRenderKey` - **v2.7.12** `overlayRenderNonce` nach jedem Style-Load - **v2.7.11** `mapLibreMap`-Key in Overlay-LaunchedEffects - **Phantom-Layer-Cleanup** vor `setStyle()`: `clearAllAnnotationsCaches` Der `Doppel-Apply-Schutz` (appliedBaseStyle-Guard) verhindert explizit die v2.7.25-Regression, bei der `styleReady` als Key einen Doppel-Reload auslöste.