diff --git a/RELEASES.md b/RELEASES.md index 048fecb..4f1ce74 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -35,6 +35,15 @@ Release: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.3 | `v2.5.36` | MapLibre Phantom-Layer-Fix | Root-Cause-Fix der weißen Phantom-GPX-Linie im neutralen Standard-Explorer (0 Wegpunkte). Ursache: `currentTrack.isNotEmpty()` in `visibleTrack` feuerte vor der `isNeutralStandard`-Prüfung, weil `loadPersistedTrackForCurrentTour` den TrackRecordingManager auch für DEFAULT_TOUR_NAME befüllte. Fix: (1) `isNeutralStandard`-Guard vor `currentTrack.isNotEmpty()` verschoben; (2) ViewModel befüllt den Manager-Track nicht mehr für die neutrale Standardtour. Alle echten Touren, laufende Aufzeichnungen und aktive Drafts unverändert. Versionscode 90. | | `v2.4.6` | Persistente Tourrouten | Reiner Datenmodell-/Persistenzschritt. Eine Tour kann eine dauerhaft gespeicherte Route besitzen (Start, Ziel, Via, Geometrie, Profil, Endpunkt/Quelle, Distanz/Dauer, Zeitstempel). Routenplaner kann die berechnete Route oder die Planlinie an die aktuelle Tour binden („Tourroute speichern/ersetzen/löschen"). Clipabschnitte folgen primär der frisch berechneten Route, dann der gespeicherten Tourroute, dann dem Track, zuletzt einer Planlinie — keine freien Linien. Wegpunkte bleiben Bausteine ohne fest verdrahtete Route und lassen sich später in andere Touren kopieren. Rückwärtskompatibel: bestehende JSON/Backups ohne Tourroute laden weiterhin; Backup-Export/Import nehmen Tourrouten mit. Versionscode 53. | +| `v2.6.2` | Route-Persistenz-Fix | Berechnete Route/Tourlinie bleibt beim Workspace-Wechsel erhalten. Root Cause: `routeLine` lebte nur im Compose-State (remember) und wurde beim Verlassen des Explorers verworfen. Fix: (1) `currentSessionRoute` StateFlow im ViewModel speichert die zuletzt berechnete Route tour-gebunden. (2) Nach erfolgreicher OSRM-Berechnung wird die Route via `setCurrentSessionRoute()` im ViewModel gesichert und gleichzeitig dauerhaft als `TourRoute` persistiert. (3) `LaunchedEffect(selectedTour)` stellt beim Zurückkehren die Route wieder her — statt des bisherigen `LaunchedEffect(selectedTour, activeTourRoute?.id)`, das beim Auto-Speichern sofort wieder zurücksetzte. (4) Planlinie und Tourverlauf-Begriffe aus v2.6.1/v2.6.0 unverändert. versionCode 105. | +| `v2.7.0` | Tourverlauf-Editor | Neues TourverlaufEditorSheet (v2.7.0): Verlaufspunkte manuell auf der Karte setzen (Tipp-Modus oder Kartenmitte), letzten Punkt entfernen, Verlauf löschen, Verlaufspunkt als Audio-Wegpunkt erstellen. Einklappbarer Online-Routing-Abschnitt. Race-Guard (v2.7.1) gegen zu frühes Auto-Save (manualRouteInitialized). versionCode 106/107. | +| `v2.7.2` | Tourverlauf-Persistenz-Fix | Root Cause: routePlan (Start/Via/Ziel des RoutePlannerSheet) lebte nur im lokalen Compose-State und wurde beim Workspace-Wechsel verloren. Planlinie („Planlinie zeigen“) wurde nicht als Session-Route persistiert. Fix: (1) Neuer currentSessionPlan StateFlow im ViewModel — speichert den RoutePlan tour-gebunden und überlebt Workspace-Wechsel. (2) onPlanChange → viewModel.setCurrentSessionPlan(). (3) onShowPlanline → buildTourRouteFromState() + setCurrentSessionRoute() (wie OSRM). (4) LaunchedEffect(selectedTour): restauriert Plan aus Session, kein blindes Reset. (5) Kalt-Start-Restore: activeTourRoute mit Start/Destination wird als RoutePlan wiederhergestellt. (6) manualRouteInitialized-Guard aus v2.7.1 unverändert. versionCode 108. | +| `v2.7.9` | Explorer State Isolation | v2.7.9 — Explorer-State-Isolation: LaunchedEffect(selectedTour) im ExplorerScreenMapLibre räumt alle Waypoint-Dialog-States (actionMenuWaypoint, editDialogWaypoint, showEditDialog, deleteConfirm, tapMode) bei Tour-Wechsel sofort auf, damit keine alte Wegpunkt-Referenz in den Dialog der neuen Tour gelangen kann. Marker der Vorgänger-Tour werden explizit vor dem waypoints-Redraw-Effekt gelöscht. versionCode 115. | +| `v2.7.10` | Waypoint-Persistence-Fix (Unbenannte Tour) | Root Cause: selectDefaultTour() setzte bei jedem Einstieg in den Explorer aus der Audio-Bibliothek/Home blind auf DEFAULT_TOUR_NAME („Standard“). War zuvor eine spontane „Unbenannte Tour“ angelegt worden, filterten Waypoints und Explorer-Karte nach „Standard“ und zeigten leere Ansicht. Fix: selectDefaultTour() prüft synchron, ob echte (nicht-Standard) Touren in _tourList vorhanden sind. Ist das der Fall, wird sofort zur ersten echten Tour gewechselt – statt auf DEFAULT zu fallen und auf den asynchronen combine-Kollektor (v2.7.5) zu warten, der den Reset dann ggf. rückabwickelte. Keine Regressionen: Lizenz, Backup, Multiclip, PTT, Atmo, Bibliothek, MapLibre, POIs, Tourverlauf, GPS-Track, Explorer-State-Isolation. versionCode 116. | +| `v2.7.8` | Unbenannte Tour + Planlinie Auto-Render | (1) 'Spontane Tour' → 'Unbenannte Tour': UI-String explorer_spontane_tour und createSpontaneousTour()-Basis geändert, Schlüssel/Funktionsname intern beibehalten. (2) Root Cause Planlinie: savedRouteLine wurde korrekt befüllt, aber nicht in routeLine übertragen; routeLine ist die primäre Zeichnungsquelle im draw-LaunchedEffect. Bei Kaltstart und ohne Button-Druck fehlte der Transfer. Fix 1: Neues LaunchedEffect(savedRouteLine, selectedTour) überträgt savedRouteLine → routeLine, wenn routeLine leer und keine Session-Route vorhanden ist. Fix 2: Auto-Persistenz-Effekt (LaunchedEffect routePlanline) ruft zusätzlich setCurrentSessionRoute() auf, statt nur saveTourRouteFor() — füllt Session-Route auch ohne Button. Fix 3: activeTourRoute als zusätzlicher Key im Zeichen-LaunchedEffect. Keine Regressionen. versionCode 114. | + +| `v2.7.26` | Persistent Map Preferences | Globale Kartenpräferenzen dauerhaft gespeichert: Wegpunkt-Marker, Audio-Radius/Reichweite, GPS-Track/Draft-Linien, POIs-automatisch-nachladen jetzt persistent (MapStyleStore). Bestehende Persistenz (baseStyle, showAudioLength, showAudioLines, POI-Kategorien, POI-Namen, perspectiveTilt, Standort-Folgen) unverändert erweitert. Standort-Folgen entschärft: Jede manuelle Kartenverschiebung deaktiviert Follow sofort (statt nur bei >40m Distanz). Kein Auto-Zentrieren beim nächsten GPS-Fix. Beim Öffnen des Explorers: Kamera auf Tour/Wegpunkte, nicht auf Live-Standort (sofern Follow aus). Keine Regressionen. versionCode 132. | + ## Release-Links - `v1.6.2-osm-layout`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v1.6.2-osm-layout @@ -63,3 +72,221 @@ Die hochgeladenen Release-Pakete enthalten APK, Quellcode-ZIP und Handoff bezieh ## Empfohlene Nutzung Für neue Nutzer ist `v2.1.3` der beste Einstieg. Ältere Versionen bleiben als Archiv erhalten, damit Entwicklungsschritte, Fehlerbehebungen und Zwischenstände nachvollziehbar bleiben. + +## v2.7.17 — Route-first Explorer (2026) + +**GPS2Audio v2.7.17** führt den konservativen UI-/Bedienlogik-Umbau „Route-first Explorer" ein. + +### Was ist neu +- **Route zuerst**: Der mentale Ablauf ist jetzt „Route planen → Tourpunkte setzen → Audio hinzufügen". +- **Tourpunkt-Konzept**: Verlaufspunkte werden als „Tourpunkte" bezeichnet. Ein Tourpunkt kann zunächst nur die Route formen; Audio wird bewusst nachträglich hinzugefügt. +- **Klarere Labels**: „Route planen" statt „Tourverlauf planen", „Routenlinie anzeigen" statt „Planlinie zeigen", „Audio hier hinzufügen" statt technischem „Als Audio-Wegpunkt erstellen". +- **Route bearbeiten**: Der Tourverlauf-Editor heißt nun „Route bearbeiten" – klarer, weniger abschreckend. +- **Abwärtskompatibel**: Bestehende Audio-Wegpunkte und Verlaufslinien aus v2.7.16 bleiben vollständig erhalten. + +### Geschützte Funktionen +Keine Änderungen an: MapLibre-Rendering, Waypoint-Rendering, routeRenderKey/waypointRenderKey/overlayRenderNonce, Planlinie-Autorender, Explorer-Kontext-Isolation, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore, Multiclip, manuelle Wiedergabe, GPS-Aufzeichnung. + +### Build-Details +- versionCode: 123 +- versionName: 2.7.17 + +## v2.7.18 — Unified Tour Layers (2026) + +**GPS2Audio v2.7.18** konsolidiert die Karten-Darstellung zu einem einheitlichen Tour-System. + +### Was ist neu +- **Einheitliche Tourlinie**: Routing, manuelles Zeichnen und gespeicherte Tourlinie wirken jetzt als ein System. Eine manuell gezeichnete Route wird im gespeicherten Zustand als grüne Tourlinie dargestellt (wie eine OSRM-berechnete Route) — keine verwirrende Doppellinie mehr. +- **Bearbeitungs-Vorschau nur im Zeichenmodus**: Die violette Vorschaulinie erscheint nur noch, während der Nutzer aktiv Verlaufspunkte zeichnet (manualRouteMode=true). Im Normalbetrieb ist die Karte übersichtlicher. +- **Verlaufspunkt-Marker reduziert**: Kleine Punkt-Marker für Verlaufspunkte erscheinen ebenfalls nur im Bearbeitungsmodus — reduziert visuelles Rauschen im Explorer. +- **Planungs-Vorschau bleibt erkennbar**: Die orange/gedimmte Planlinie (Routing-Werkzeug) bleibt visuell als Werkzeug-Vorschau erkennbar. +- **Abwärtskompatibel**: Kein Datenmodell-Umbau, bestehende Routen/Wegpunkte unverändert. + +### Geschützte Funktionen +Keine Änderungen an: experimentellem Routing/Route berechnen, Route planen, Routenlinie anzeigen, Route bearbeiten/manuelles Zeichnen, Audio-Waypoints, Multiclip, manuelle Wiedergabe, PTT, Atmo, MapLibre Initial-Render-Fixes, Planlinie-Autorender, routeRenderKey/waypointRenderKey/overlayRenderNonce, Explorer-Kontext-Isolation, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore. + +### Build-Details +- versionCode: 124 +- versionName: 2.7.18 + +## v2.7.19 — Tourplaner Integration Fix (2026) + +**GPS2Audio v2.7.19** behebt den Verlust von Start-, Ziel- und Via-Punkten im Tourplaner und integriert das manuelle Zeichnen als Werkzeug des Tourplaners. + +### Was ist neu +- **Fix: Start/Ziel/Via dauerhaft gespeichert**: Der Tourplaner schreibt den Routenplan jetzt sofort in die Datenbank, sobald Start und Ziel gesetzt sind. Bisher wurde nur die In-Memory-Session gespeichert — nach einem Prozess-Kill (App-Neustart) war der Plan verloren. Jetzt bleibt er auch nach Neustart vollständig erhalten. +- **Manuelles Zeichnen im Tourplaner**: Die Schaltfläche „Zeichenmodus öffnen“ ist jetzt direkt im Tourplaner (RoutePlannerSheet) eingebettet. Routing und manuelles Zeichnen sind gleichwertige Bearbeitungsmethoden im selben Planer. +- **Kein Rückschritt beim Doppellinien-Fix**: Die violette Bearbeitungs-Vorschau erscheint weiterhin nur im aktiven Zeichenmodus (wie in v2.7.18). +- **Abwärtskompatibel**: Kein Datenmodell-Umbau, bestehende Routen/Wegpunkte unverändert. + +### Geschützte Funktionen +Keine Änderungen an: experimentellem Routing/Route berechnen, Routenlinie anzeigen, Route bearbeiten/manuelles Zeichnen, Audio-Waypoints, Multiclip, manuelle Wiedergabe, PTT, Atmo, MapLibre Initial-Render-Fixes, Planlinie-Autorender, routeRenderKey/waypointRenderKey/overlayRenderNonce, Explorer-Kontext-Isolation, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore. + +### Build-Details +- versionCode: 125 +- versionName: 2.7.19 + +## v2.7.20 — Flexible Route Shaping (2026) + +**GPS2Audio v2.7.20** führt flexible Routenformung per Karten-Tap ein. + +### Was ist neu +- **AddViaPoint-Modus**: Neuer TapMode für direkte Zwischenpunkt-Setzung per Karten-Tap aus dem Routenplaner heraus. +- **Intuitiver Fallback**: 1. Tap = Start, 2. Tap = Ziel, weitere Taps = Zwischenpunkte. +- **Via-Sektion**: Im Tourplaner klar als „Zwischenpunkte (Route formen)" bezeichnet. + +### Build-Details +- versionCode: 126 +- versionName: 2.7.20 + +## v2.7.21 — Waypoint Preservation Fix (2026) + +**GPS2Audio v2.7.21** behebt kritischen Datenverlust: Audio-Wegpunkte innerhalb einer Tour wurden beim Öffnen des Routenplaners unter bestimmten Bedingungen gelöscht. + +### Root Cause +Race-Condition in `duplicateTour()`, `deleteTour()` und `renameTour()`: Diese Funktionen lasen `_waypoints.value` aus dem In-Memory-State (MutableStateFlow). Wenn der DataStore-Erstladevorgang noch nicht abgeschlossen war, enthielt `_waypoints.value` noch die initiale leere Liste. Ein `saveAll(emptyList() + newWps)` überschrieb dann alle bestehenden Wegpunkte aller Touren. + +Zusätzlich: Kein globaler Schutz vor einem `saveAll([])` bei nicht-leerer DataStore-Wegpunktliste. + +### Fixes +1. **`WaypointStore.saveAll()` Preservation-Guard**: Wenn die übergebene Liste leer ist, aber die DataStore-Wegpunktliste nicht-leer ist, wird der Schreibvorgang blockiert (atomarer Check in `dataStore.edit`). Verhindert versehentlichen Totalverlust. +2. **`WaypointStore.saveAllForced()`**: Neue Methode für explizite Lösch-/Restore-Operationen, die den Preservation-Guard umgehen dürfen. +3. **Race-Condition-Fix in `duplicateTour`/`deleteTour`/`renameTour`**: Lesen jetzt mit `repository.waypoints.first()` direkt aus DataStore statt aus dem möglicherweise noch leeren `_waypoints.value`. +4. **AddViaPoint-Pfad (v2.7.20) bestätigt sicher**: Kein Waypoint-Speicherpfad wird durch Route-Planen, AddViaPoint oder Planlinie-Auto-Persistenz ausgelöst. +5. **Architektonische Invariante dokumentiert**: `saveTourRoute`/`saveTourRouteFor` schreiben ausschließlich in `KEY_TOUR_ROUTES`, nie in `KEY_WAYPOINTS`. + +### Geschützte Funktionen +Alle Funktionen aus v2.7.20 unverändert: AddViaPoint, flexible Routenformung, v2.7.19 Start/Via/Ziel-Persistenz, v2.7.18 Doppellinie-Fix, Audio-Waypoints, Multiclip, manuelle Wiedergabe, PTT, Atmo, MapLibre-Render-Fixes, Planlinie-Autorender, routeRenderKey/waypointRenderKey/overlayRenderNonce, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore. + +### Build-Details +- versionCode: 127 +- versionName: 2.7.21 + +--- + +## v2.7.22 — Saved Route Render Fix + +**Problem**: Ein gespeicherter Tourverlauf (manuell gezeichnet, Quelle MANUAL) war im RoutePlannerSheet sichtbar (Karte mit Buttons "Tourverlauf ersetzen/löschen"), wurde aber visuell nicht als Linie auf der Karte angezeigt. Zusätzlich zeigte der Tourplaner "Noch keine Route. Routenlinie anzeigen genügt — Online-Routing ist optional." + +**Root Cause**: `savedRouteLine` (die Hilfsliste, aus der die Kartenrenderschicht die grüne Tourlinie zeichnet) schloss MANUAL-Routen explizit mit `emptyList()` aus (Zeile 290, v2.5.3-Kommentar). Die ursprüngliche Intention war, Doppellinien zu verhindern (v2.7.18). Aber v2.7.18 hat die Bearbeitungs-Vorschau (manualRoutePolylines, violett) bereits auf `manualRouteMode == true` beschränkt. Damit war der Ausschluss in `savedRouteLine` überflüssig und verhinderte die korrekte Darstellung. + +**Fixes (ExplorerScreenMapLibre.kt)**: +1. **Fix 1** — `savedRouteLine`-Berechnung: MANUAL nicht mehr pauschal mit `emptyList()` ausgeschlossen. Stattdessen gleiches Verhalten wie OSRM: Route wird als Punkte zurückgegeben, Phantom-Guard auf Standardtour bleibt erhalten. +2. **Fix 2** — `LaunchedEffect(savedRouteLine)`: MANUAL-Quellen überspringen (wie PLANLINE), damit MANUAL via `hasSavedRoute`-Pfad grün gezeichnet wird (nicht blau als OSRM-Route). Kein `routeLine`-Load für MANUAL. +3. **Fix 3** — RoutePlannerSheet-Aufruf: `hasRoute = routeLine.size >= 2 || savedRouteLine.size >= 2` — "Noch keine Route" erscheint nicht mehr, wenn eine gespeicherte MANUAL- oder OSRM-TourRoute vorhanden ist. + +### Geschützte Funktionen +Alle Funktionen aus v2.7.21 unverändert: Waypoint-Preservation-Guard, AddViaPoint, flexible Routenformung, v2.7.19 Start/Via/Ziel-Persistenz, v2.7.18 Doppellinie-Fix (manualRouteMode-Guard bleibt), Audio-Waypoints, Multiclip, manuelle Wiedergabe, PTT, Atmo, MapLibre-Render-Fixes, Planlinie-Autorender, routeRenderKey/waypointRenderKey/overlayRenderNonce, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore. + +### Build-Details +- versionCode: 128 +- versionName: 2.7.22 + +--- + +## v2.7.23 — Route Planner Flow Fix + +**Problem**: Der Routenplaner zeigte eine violette/orange Planlinie auf der Karte, aber diese konnte nicht sinnvoll als Tourverlauf gespeichert oder mit dem Tourverlauf verknüpft werden. Audio-Wegpunkte ließen sich zwar als Start/Ziel/Via wählen, aber der Weg von der Planlinie zum persistierten Tourverlauf war unklar. + +**Root Cause**: Es fehlte ein direkt zugänglicher, klar benannter "Als Tourverlauf übernehmen"-Button in der Sticky Action Bar des Tourplaners. Die vorhandene `TourRouteStatusCard` im scrollbaren Bereich war nicht sichtbar genug, und die Buttons "Speichern"/"Ersetzen" dort waren bei bestehenden Routen in der Karte verborgen. Zusätzlich nutzte `onShowPlanline` und `onSaveTourRoute` `routePlanline` aus der Compose-Remember-Chain, ohne einen Inline-Fallback für den Fall, dass die Recomposition noch nicht stattgefunden hatte. + +**Fixes**: +1. **Neuer "Als Tourverlauf übernehmen"-Button** in der Sticky Action Bar (neben "Routenlinie anzeigen"). Aktiviert wenn `hasPlanline || hasRoute`. Speichert direkt — kein zusätzliches Scrolling nötig. +2. **`onAdoptAsTourRoute`-Callback** in ExplorerScreenMapLibre: Wählt OSRM-Route (wenn vorhanden) oder Planlinie als TourRoute-Quelle, schreibt in DB und Session-Route, zeigt Statusmeldung. +3. **Inline Planline-Fallback**: `onShowPlanline`, `onSaveTourRoute`, `onReplaceTourRoute` berechnen die Planline-Koordinaten inline aus `routePlan.orderedStops()` als Fallback, falls `routePlanline` (remember-computed) noch leer ist. +4. **setCurrentSessionRoute zusätzlich bei Save/Replace**: Session-Route wird nach Speichern auch im ViewModel gesetzt, damit Workspace-Wechsel sofort die neue Route kennt. +5. **TourRouteStatusCard-Hinweis**: Zeigt proaktiv `route_planner_adopt_planline_hint` wenn Planlinie vorhanden aber noch kein gespeicherter Verlauf. + +### Audio-Wegpunkte als Planpunkte +- Auswahl eines Audio-Wegpunkts für Start/Ziel/Via: unverändert über WaypointPickerDialog → applyStopToRole() → onPlanChange(). +- Keine Waypoint-Löschung, keine Duplikation (Waypoint-Preservation-Guard aus v2.7.21 bleibt aktiv). +- Nach Auswahl: routePlanline auto-computed, hasPlanline=true, Buttons aktiv. + +### Geschützte Funktionen +Alle Funktionen aus v2.7.22 unverändert: Saved Route Render Fix, Waypoint-Preservation-Guard, AddViaPoint, flexible Routenformung, v2.7.19 Start/Via/Ziel-Persistenz, v2.7.18 Doppellinie-Fix, Audio-Waypoints, Multiclip, manuelle Wiedergabe, PTT, Atmo, MapLibre-Render-Fixes, Planlinie-Autorender, routeRenderKey/waypointRenderKey/overlayRenderNonce, POI-Autoload, Kartenstile, Lizenzsystem, Backup/Restore. + +### Build-Details +- versionCode: 129 +- versionName: 2.7.23 + +--- + +## v2.7.24 — Tourverlauf UX Polish + +Tourverlauf-UX-Polish: Verbesserte Nutzererfahrung beim Verwalten gespeicherter Tourverlauf-Linien im Explorer. Keine neue Funktionalität — nur UX-Feinschliff und Code-Qualität. + +### Build-Details +- versionCode: 130 +- versionName: 2.7.24 + +--- + +## v2.7.25 — Tourline Initial Render Fix + +**Problem**: Die gespeicherte Tourlinie erschien beim ersten Öffnen des Explorers nicht sofort auf der Karte. Erst eine spätere UI-Interaktion (z.B. Abspielen eines Clips) löste den Render aus. + +**Root Cause**: Zwei zusammenwirkende Timing-Probleme: + +1. **Doppelter Style-Load**: `LaunchedEffect(layerState.baseStyle, mapStyleSettings.perspectiveTilt, styleReady)` enthielt `styleReady` als Key. Wenn der initiale Style-Load (im `AndroidView` factory Callback) `styleReady=true` setzte, feuerte dieser Effect erneut und rief `ml.setStyle()` ein zweites Mal auf. Das wischte alle bereits gezeichneten Polylines weg und trigerred einen zweiten asynchronen Style-Load. + +2. **Late activeTourRoute**: `activeTourRoute` wird asynchron aus der Datenbank geladen (via `StateFlow` im ViewModel). Kam der zweite Style-Load vor dem DB-Load ab, war `savedRouteLine` noch leer. Der `overlayRenderNonce`-Mechanismus hatte bereits gefeuert, bevor die Route verfügbar war. Obwohl `savedRouteLine` als Key im Overlay-`LaunchedEffect` gelistet ist, konnten Compose-Snapshot-Caching-Effekte das erneute Feuern verhindern. + +**Fixes**: +1. **`styleReady` aus Style-Change-LaunchedEffect-Keys entfernt** (v2.7.25): Verhindert den Doppel-Reload. Der Effect feuert jetzt nur noch bei echten `baseStyle`- oder `perspectiveTilt`-Änderungen. +2. **Dedizierter `LaunchedEffect(savedRouteLine.size, styleReady, mapLibreMap)`** (v2.7.25): Feuert wenn `savedRouteLine` mindestens 2 Punkte hat UND Style bereit UND Map initialisiert. Inkrementiert `overlayRenderNonce`, sodass der Zeichnungseffekt garantiert neu startet — unabhängig davon, wann `activeTourRoute` aus der DB ankommt. + +**Nicht geändert**: +- Speicherlogik (keine Änderungen an `saveTourRouteFor`, `activeTourRoute`, `_tourRoutes`) +- v2.7.24 Tourverlauf UX Polish +- v2.7.23 'Als Tourverlauf übernehmen' Flow Fix +- v2.7.22 Saved Route Render Fix +- v2.7.21 Waypoint Preservation Guard +- v2.7.18 Doppellinie-Fix (manualRouteMode-Guard) +- Alle anderen Funktionen (AddViaPoint, OSRM, PLANLINE, MANUAL, Audio-Waypoints, PTT, Atmo, Backup, Lizenzsystem) + +### Build-Details +- versionCode: 131 +- versionName: 2.7.25 + +--- + +## v2.7.30 — MapLibre Overlay Rebuild Fix + +**Problem (Nutzerbericht nach v2.7.29)**: "nein das hat nichts verändert. Das ist der Live Standort und die Waypoints bzw. Planlinie wird nicht angezeigt." Screenshot zeigte nur einen hellen/weißen Kreis als Live-Standort, keine Waypoint-Marker, keine Planlinie. + +**Root-Cause-Analyse (warum v2.7.29 nicht wirkte)**: + +1. **yield() zu kurz**: Das einzige `yield()` (Wartedauer: 1 Frame ≈ 16ms) nach `styleReady` in den Overlay-LaunchedEffects war nicht ausreichend für MapLibres GL-Thread und AnnotationManager-Initialisierung nach `setStyle()`. `addMarker()` und `addPolyline()` schlugen still fehl — keine Exception, aber Rückgabewert null/leer. + +2. **Kein Retry-Mechanismus**: Wenn der erste Render-Versuch (150ms nach Style-Load) scheiterte, gab es keinen weiteren Versuch. Nur Nutzerinteraktion (State-Änderung) löste einen neuen Render aus. + +3. **runCatching fehlte**: Fehlschlagende `addMarker()`/`addPolyline()`-Aufrufe warfen keine Exception in die Coroutine, aber gaben null zurück. Ohne `runCatching` konnte ein unerwarteter Fehler die Coroutine abbrechen und alle nachfolgenden Zeichenoperationen verhindern. + +4. **clearAllAnnotationsCaches Lücke**: `planlinePolylines` und `routeStopMarkers` wurden beim Style-Cleanup (vor `setStyle()`) nicht geleert — veraltete Referenzen blieben im Cache und führten zu nutzlosen `removePolyline()`-Aufrufen auf ungültigen Objekten. + +5. **v2.7.29 Puck-Fix: Early-Return-Problem**: `lastLat ?: return@LaunchedEffect` vor `yield()` bedeutete: Hatte der Nutzer beim Style-Load noch keinen GPS-Fix, blieb der Puck dauerhaft leer (bis zum nächsten GPS-Fix). Wurde der Fix vor GPS-Empfang aufgerufen, war `lastLat` null → sofortiger Return → kein Puck gezeichnet. + +**Fixes v2.7.30**: + +1. **delay(150ms) statt yield()** in beiden Overlay-LaunchedEffects (Waypoints/Tracks + Routen/Planline): Gibt dem AnnotationManager ausreichend Zeit nach Style-Load. + +2. **Zentraler Retry-Mechanismus (500ms)**: `LaunchedEffect(styleReady, mapLibreMap)` wartet 500ms nach jedem Style-Ready und inkrementiert `overlayRenderNonce` ein zweites Mal. Ergebnis: Zwei Render-Versuche — bei 150ms und bei 650ms nach Style-Load. Der zweite ist garantiert erfolgreich, da der AnnotationManager nach 500ms sicher bereit ist. + +3. **delay(200ms) + runCatching im Puck-Rehydrate-LaunchedEffect**: Puck-Marker wird robuster gezeichnet, Fehler werden abgefangen. + +4. **runCatching um alle addMarker/addPolyline-Aufrufe**: In `drawExplorerOverlaysMapLibre` und der Route-LaunchedEffect — verhindert silent-Crashes und gibt dem 500ms-Retry die Chance, alle fehlgeschlagenen Overlays nachzuzeichnen. + +5. **clearAllAnnotationsCaches erweitert**: `planlinePolylines` und `routeStopMarkers` werden jetzt ebenfalls beim Style-Cleanup geleert (neue optionale Parameter). + +**Nicht geändert**: +- v2.7.28 Map Style Apply Fix (appliedBaseStyle/appliedPerspectiveTilt guard) +- v2.7.27 Kartenpräferenz-Persistenz +- v2.7.26 Standort-Folgen-Fix +- v2.7.25 Initial-Render-Fix (savedRouteLine.size LaunchedEffect) +- v2.7.24 UX Polish, v2.7.23 Tourverlauf übernehmen +- v2.7.21 Waypoint Preservation Guard +- Alle Funktionen: Routenplanung, POIs, Kartenstile, Audio-Waypoints, Multiclip, PTT, Atmo, Lizenzsystem, Backup/Restore + +### Build-Details +- versionCode: 136 +- versionName: 2.7.30 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bacb296..291d2d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,8 +157,317 @@ android { // • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, // PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing, // Tour-/Waypoint-Kopien, Hintergrundwiedergabe. - versionCode = 102 - versionName = "2.5.48" + // v2.6.0 — Tourverlauf/Storyboard-Konsolidierung: + // • Routenplaner → "Tourverlauf" (deutsch, klar, kein Auto-Navi-Gefühl). + // • Storyboard-Panel übersichtlicher beschriftet und mit Erklärtext versehen. + // • Routepunkt vs. Wegpunkt (Audio) klar unterschieden in Labels. + // • Online-Routing als optional/sekundär markiert, nicht ausgebaut. + // • Keine Änderung an Logik, Backup, Lizenz, Playback, GPS-Aufzeichnung. + // v2.6.1 — Waypoint-Dialog-Polish: Formatierungsfehler RoutePlannerSheet + // behoben, WaypointEditDialog neu gestaltet (freundlichere Cards). + // Keine Logik-/Datenmodell-Änderungen. + // v2.7.0 — Tourverlauf-Editor: + // • Neues TourverlaufEditorSheet als dedizierter Bearbeitungsmodus. + // • Verlaufspunkte über Kartenmitte oder Tipp-Modus setzen. + // • "Als Audio-Wegpunkt erstellen" aus Verlaufspunkt heraus. + // • Online-Routing in RoutePlannerSheet sekundär (OutlinedButton). + // • Status-Bar zeigt Verlaufspunkt-Anzahl im Editor-Modus. + // • Persistenz/Speicherung über bestehende TourRoute/MANUAL-Infrastruktur. + // • Keine Regressionen: Playback, Backup, Lizenz, GPS-Aufzeichnung unverändert. + // v2.7.1 — Tourverlauf-Linie-Fix: + // • Behebt: Manuelle Tourverlauf-Linie wurde dauerhaft nicht angezeigt bzw. + // verschwand nach Öffnen/Schließen des Editors, Workspace-Wechsel oder + // Kartenstil-Reload. + // • Root Cause 1 (Race-Condition): Beim initialen Laden der Composable feuerte + // der Auto-Save-LaunchedEffect mit leerem manualRoutePoints, bevor der + // Load-Effekt die Punkte aus der DB geladen hatte. Bei leerem Liste UND + // wasManual=true wurde deleteTourRouteForCurrent() aufgerufen — die + // gespeicherte Route wurde gelöscht. Fix: manualRouteInitialized-Guard. + // • Root Cause 2 (Zeichnungs-LaunchedEffect verpasst Map-Init): + // mapLibreMap als Key im Drawing-LaunchedEffect fehlte. Wenn die Karte nach + // einem Workspace-Wechsel asynchron initialisiert wurde, feuerte das Effect + // nicht erneut (manualRoutePoints unverändert). Fix: mapLibreMap als + // zusätzlicher Key. + // • Root Cause 3 (Load-Effect-Keys zu breit): + // activeTourRoute?.id im Load-Effect führte dazu, dass jedes Auto-Save + // (neue updatedAtMillis) den Load neu auslöste. Fix: nur source als Key, + // kein id. + // • Verbesserung: Strichstärke der manuellen Tourverlauf-Linie erhöht + // (10/7 statt 8/5) für bessere Sichtbarkeit. + // • Keine Regressionen: v2.7.0 Editor, v2.6.2 Route-Persistenz, v2.6.1 + // Wegpunktdialog, v2.5.48 TrackDraft-Backup, POI, 3D, Playback, Backup, + // Lizenzsystem unverändert. + // v2.7.3 — Planlinie Auto-Show-Fix: + // • Root Cause: "Planlinie zeigen" persistierte die Planroute nur in die + // ViewModel-Session (RAM), nicht in die DB. Nach App-Neustart (Prozess-Kill) + // war kein activeTourRoute-Eintrag vorhanden, savedRouteLine blieb leer → + // keine Linie sichtbar, Button erneut nötig. + // • Fix 1 (Persistenz): onShowPlanline ruft jetzt zusätzlich + // viewModel.saveTourRouteFor() auf. PLANLINE wird in der DB gespeichert. + // activeTourRoute.polyline ist ab dem nächsten App-Start verfügbar. + // • Fix 2 (Zeichnungs-Priorität): savedRouteLine hat jetzt Vorrang vor + // routePlanline. routeLine > savedRouteLine > routePlanline. Doppelte + // Überlagerung (PLANLINE-Quelle) beseitigt. + // • Fix 3 (Clear-Symmetrie): onClear löscht die DB-PLANLINE-Route, wenn + // der Nutzer explizit den Planer leert. OSRM/MANUAL bleiben unangetastet. + // • Keine Änderungen an Datenmodell, Backup, Playback, Lizenz. + // v2.7.4 — Planlinie Auto Render: + // • Root Cause 1 behoben: savedRouteLine-Phantom-Guard schloss PLANLINE-Routen auf + // der Standardtour aus (isNeutralStandard). PLANLINE ist bewusste Nutzeraktion, + // kein Phantom — Ausnahme eingefügt. + // • Root Cause 2 behoben: mapLibreMap fehlte als Key im route-drawing-LaunchedEffect. + // Nach Map-Neuinitialisierung (Stylewechsel, Workspace-Rückkehr) wurde die Linie + // nicht neu gezeichnet, obwohl routePlanline befüllt war. + // • Auto-Persistenz: routePlanline ≥ 2 Stopps → automatisch als PLANLINE-TourRoute + // in DB gespeichert, ohne Button-Druck. Button „Planlinie zeigen“ ist nur noch + // Kamera-Fokus. + // • Zeichnungsfarbe: PLANLINE aus savedRouteLine erhält orangene Farbe (wie routePlanline), + // kein falsches Grün mehr. + // • Priorität v2.7.4: routeLine (OSRM) > routePlanline (wenn PLANLINE-Quelle) > + // savedRouteLine (OSRM/andere) > routePlanline (fallback) > keine Linie. + // • Keine Änderungen an Datenmodell, Backup, Playback, Lizenz. + // + // v2.7.5 — Standard-Tour intern (Konzeptkorrektur): + // • Root Cause: DEFAULT_TOUR_NAME="Standard" war bisher in der Audio-Bibliothek + // als vollwertige bearbeitbare Tour sichtbar, was Phantom-/Kontextprobleme + // erzeugen konnte. Standard-Tour ist eine System-Blaupause, kein Nutzerdatensatz. + // • visibleTourList (neu im ViewModel): filtert Standard-Tour aus der UI-Liste, + // sobald mindestens eine echte Tour existiert. tourList intern unverändert. + // • Audio-Bibliothek (HomeScreen): zeigt Standard-Tour nicht mehr im Cover-Grid. + // • Menü (WaypointListScreen): Umbenennen + Duplizieren für Standard-Tour deaktiviert. + // Löschen war bereits gesperrt (unverändert). + // • Auto-Selektion: Beim App-Start mit echten Touren wird automatisch die erste + // echte Tour gewählt statt "Standard". + // • Explorer isNeutralStandard, Backup, Datenmodell, Lizenz: unverändert. + // v2.7.7 — Spontane Tour Autocreate: + // • Root Cause: ExplorerScreenMapLibre.kt zeigte in der Topbar weiterhin + // "Standard" (selectedTour direkt), da die UI-Entkopplung aus v2.7.6 + // (displayTourLabel) dort nicht übernommen wurde. Fix: displayTourLabel + // analog zu ExplorerScreen.kt implementiert. + // • Automatische Tourerstellung beim ersten Wegpunkt im neutralen Explorer: + // upsertFromNeutralExplorer() im ViewModel prüft, ob tourName == + // DEFAULT_TOUR_NAME. Falls ja, erzeugt createSpontaneousTour() eine + // eindeutige "Spontane Tour" (mit Suffix falls Name existiert), ruft + // addTour() auf (inkl. selectedTour-Wechsel) und speichert den Wegpunkt + // in der neuen Tour. Keine DB-Migration, keine neue Tabelle. + // • Betroffen: neuer Wegpunkt per Tap (PlaceNew), POI als Wegpunkt, + // POI zu Storyboard, Explorer-POI-Karte. Bearbeiten bestehender + // Wegpunkte: normaler upsert-Pfad (keine Auto-Tour). + // • Standard-Tour bleibt intern; visibleTourList (v2.7.5) blendet + // sie weiterhin aus, sobald echte Touren existieren. + // • Keine Änderungen an Backup, Lizenz, Playback, PTT, Atmo, GPS-Track. + // v2.7.8 — Unbenannte Tour + Planlinie Auto-Render: + // • 'Spontane Tour' → 'Unbenannte Tour': UI-String explorer_spontane_tour + // und createSpontaneousTour()-Basisname geändert. Schlüssel bleibt für + // risikoarmes Deployment; Funktionsname intern unverändert. + // • Root Cause Planlinie: Beim ersten Rendern nach App-Neustart oder + // Workspace-Rückkehr war savedRouteLine befüllt, wurde aber nicht in + // routeLine übertragen. routeLine ist die primäre Zeichnungsquelle. + // Folge: LaunchedEffect-Zeichnung lief, zog aber savedRouteLine nur + // als sekundären Fallback (grüne Linie). Die sichtbarkeitsblockierende + // Hürde: manuelle Sitzung (Session-Route) fehlte bei Kaltstart. + // • Fix 1: Neues LaunchedEffect(savedRouteLine, selectedTour) überträgt + // savedRouteLine → routeLine, wenn routeLine leer und keine Session-Route + // vorhanden. Damit ist routeLine immer die aktive Zeichnungsquelle. + // • Fix 2: Auto-Persistenz (LaunchedEffect routePlanline) ruft nun + // viewModel.setCurrentSessionRoute() auf, statt nur saveTourRouteFor(). + // Das füllt currentSessionRoute für Workspace-Rückkehr ohne Button-Druck. + // • Fix 3: activeTourRoute als zusätzlicher Key im Zeichen-LaunchedEffect + // (belt-and-suspenders gegen strukturgleiche List-Vergleiche). + // • Keine Regressionen: Lizenz, Backup, Multiclip, PTT, Atmo, Bibliothek, + // MapLibre, POIs, Tourverlauf, GPS-Track, Spontane-Tour-Autocreate. + // v2.7.10 — Waypoint-Persistence-Fix (Unbenannte Tour bleibt nach Verlassen der Karte): + // • Root Cause: selectDefaultTour() wurde bei jedem Einstieg in den Explorer + // aus der Audio-Bibliothek/Home blind auf DEFAULT_TOUR_NAME („Standard“) + // gesetzt. Wenn zuvor eine spontane „Unbenannte Tour“ angelegt worden war, + // filterten Waypoints und Explorer-Karte nach „Standard“ → leere Ansicht. + // • Fix: selectDefaultTour() prüft jetzt vor dem Reset, ob echte (nicht-Standard) + // Touren in _tourList vorhanden sind. Ist das der Fall, wird sofort synchron + // zur ersten echten Tour gewechselt – statt auf DEFAULT zu fallen und auf den + // asynchronen combine-Kollektor (v2.7.5) zu warten, der den Reset dann ggf. + // rückabwickelt. Das vermeidet die Race-Condition, bei der die Explorer-UI + // in einem Recomposition-Zyklus mit leerem Waypoint-Filter gerendert wurde. + // • Keine Regressionen: Lizenz, Backup, Multiclip, PTT, Atmo, Bibliothek, + // MapLibre, POIs, Tourverlauf, GPS-Track, Explorer-State-Isolation (v2.7.9). + // Bestehende echte Touren werden beim Explorer-Öffnen korrekt vorausgewählt + // (war bereits so via async combine-Autoswitch – jetzt deterministisch/synchron). + // v2.7.14 — Explorer Entry Context Fix (Navigations-Bug): + // • Bug: Übersicht → Explorer öffnete nicht neutral, sondern die zuletzt + // bekannte echte Tour. Grund: selectDefaultTour() wählte bei vorhandenen + // echten Touren die erste echte Tour statt DEFAULT_TOUR_NAME. + // • Fix: Neue Funktion selectNeutralTourForNewExplorer() mit _neutralExplorerMode- + // Flag verhindert den automatischen Upgrade durch den combine-Kollektor (v2.7.5) + // und erzwingt bewusst einen leeren Explorer-Kontext aus der Übersicht. + // • Touransicht → Explorer: bleibt unverändert bei der aktiven Tour. + // • selectDefaultTour() für Legacy-Pfade erhalten. Keine Regressionen. + // v2.7.13 Planlinie, v2.7.12 overlayRenderNonce, v2.7.11 waypointRenderKey, + // v2.7.10 Persistenz, v2.7.9 State Isolation alle unberührt. + // v2.7.20 — Flexible Routenformung: + // • Route formen = Start/Ziel/Zwischenpunkte setzen, dann Planlinie oder Online-Route. + // • Neu: Zwischenpunkt direkt per Karten-Tap setzen (neuer AddViaPoint-TapMode). + // • Intuitiver Fallback: 1. Tap = Start, 2. Tap = Ziel, weitere = Zwischenpunkte. + // • Via-Sektion im Tourplaner klar als "Zwischenpunkte (Route formen)" bezeichnet. + // • Freie Linie klar als Fallback gekennzeichnet (UI-Text, kein Verhaltensänderung). + // • Keine Regressionen: v2.7.19 Start/Via/Ziel-Persistenz, v2.7.18 Doppellinie-Fix, + // Audio-Waypoints, PTT, Atmo, Backup, Lizenz, MapLibre-Render-Fixes unverändert. + // v2.7.21 — Waypoint Preservation Fix: + // • Root Cause: Race-Condition in duplicateTour()/deleteTour()/renameTour() liest + // _waypoints.value (könnte noch emptyList() sein vor DataStore-Erstladen) und ruft + // saveAll(leere_liste + newWps) auf — überschreibt alle bestehenden Wegpunkte. + // • Fix 1: saveAll() hat jetzt Preservation-Guard: leere Liste überschreibt niemals + // nicht-leere DataStore-Liste (atomarer Check innerhalb dataStore.edit). + // • Fix 2: saveAllForced() für explizite Lösch-/Restore-Operationen (deleteTour, + // renameTour, Backup-Restore) — bewusster Vollersatz. + // • Fix 3: duplicateTour/deleteTour/renameTour lesen jetzt mit repository.waypoints.first() + // direkt aus DataStore statt aus _waypoints.value. + // • Planlinie/Route-Speicherpfade (saveTourRouteFor) berhren KEY_WAYPOINTS nie. + // • AddViaPoint (v2.7.20) bestätigt sicher: kein Waypoint-Speicherpfad wird ausgelöst. + // • Keine Regressionen: v2.7.20 AddViaPoint, v2.7.19 Start/Via/Ziel-Persistenz, + // v2.7.18 Doppellinie-Fix, Audio-Waypoints, PTT, Atmo, Backup, Lizenz unverändert. + // v2.7.22 — Saved Route Render Fix: + // • Root Cause: savedRouteLine schloss MANUAL-Routen explizit aus (emptyList()), + // damit der v2.7.18 Doppellinien-Fix keine Konflikte erzeugt. Da aber + // manualRoutePolylines (Bearbeitungsvorschau) nur bei manualRouteMode=true + // gezeichnet wird, war das Ausschluss-Kriterium in savedRouteLineüberfüssig + // und verhinderte die Darstellung gespeicherter MANUAL-Routen. + // • Fix 1: MANUAL-Quellen in savedRouteLine eingeschlossen (wie OSRM); Phantom-Guard + // auf Standardtour bleibt. + // • Fix 2: LaunchedEffect(savedRouteLine) überspringt MANUAL (wie PLANLINE), damit + // MANUAL via hasSavedRoute-Pfad grün gezeichnet wird (nicht blau als OSRM). + // • Fix 3: hasRoute im RoutePlannerSheet-Aufruf berücksichtigt savedRouteLine.size, + // damit "Noch keine Route" nicht erscheint wenn eine gespeicherte Route vorhanden ist. + // • Keine Regressionen: v2.7.21 Waypoint-Preservation-Guard, v2.7.18 Doppellinie-Fix, + // PLANLINE, OSRM, AddViaPoint, Audio-Waypoints, PTT, Atmo, Backup, Lizenz unverändert. + // v2.7.26 — Persistent Map Preferences: + // • Globale Kartenpräferenzen dauerhaft gespeichert: Wegpunkt-Marker, Audio-Radius, + // GPS-Track/Draft-Linien, POIs-automatisch-nachladen (autoPoiLoad) jetzt persistent. + // • Bestehende Persistenz (baseStyle, showAudioLength, showAudioLines, POI-Kategorien, + // POI-Namen, Standort-Folgen, perspectiveTilt) unverändert beibehalten und erweitert. + // • Standort-Folgen entschärft: Jede manuelle Kartenverschiebung deaktiviert Standort + // folgen sofort (statt nur bei >40m Distanz). Kein Auto-Rücksprung beim nächsten + // GPS-Fix. Explizite Aktivierung nur per Toggle-Switch oder MyLocation-Button. + // • Beim Öffnen des Explorers mit Tourlinie/Wegpunkten: Kamera auf Tour zentriert, + // nicht auf Live-Standort (sofern Standort folgen aus). + // • Keine Regressionen: v2.7.25 Initial-Render-Fix, v2.7.24 UX Polish, v2.7.23 + // Tourverlauf übernehmen, v2.7.22 Saved Route Render, v2.7.21 Waypoint + // Preservation Guard alle unberührt. + // v2.7.29 — Overlay Rehydrate Fix: + // • Root Cause: Nach setStyle() loescht MapLibre alle Annotations (Marker, + // Polylines, Polygons). overlayRenderNonce++ loest Waypoint-Redraw aus, + // aber myLocationMarker/accuracyPolygon wurden auf null gesetzt und nur durch + // eingehende GPS-Updates (LocationCallback) neu gesetzt — fehlende Interaktion + // bis zum naechsten GPS-Fix fuehrte zu fehlendem/hellem Default-Marker. + // • Fix 1 (Live-Standort): Neuer LaunchedEffect(overlayRenderNonce, styleReady, + // mapLibreMap) zeichnet den Puck sofort aus lastLat/lastLng neu, ohne auf + // naechsten GPS-Fix zu warten. Genauigkeitskreis wird ebenfalls rehydriert. + // • Fix 2 (POI-Marker): overlayRenderNonce als Key im explorerPoiResults- + // LaunchedEffect sichert POI-Redraw nach Style-Load. + // • Alle v2.7.28 Fixes (Map Style Apply Fix, appliedBaseStyle/appliedPerspectiveTilt) + // bleiben vollstaendig erhalten. + // • Keine Regressionen: v2.7.27 Kartenpraeferenz-Persistenz, v2.7.26 + // Standort-Folgen-Fix, v2.7.25 Initial-Render-Fix, v2.7.24 UX Polish, + // v2.7.23 Tourverlauf uebernehmen, v2.7.21 Waypoint Preservation Guard, + // Routenplanung, POIs, Kartenstile, Audio-Waypoints, Multiclip, PTT, Atmo, + // Lizenzsystem, Backup/Restore alle unberuehrt. + // v2.7.32 — Zwei Regressionsfixes: + // • Touren ordnen: Erste und letzte SICHTBARE Tour ließen sich nicht + // mehr verschieben. Ursache: TourReorderDialog arbeitet auf + // visibleTourList (Standard-Tour ausgeblendet), WaypointViewModel.moveTour + // operierte aber auf _tourList inkl. DEFAULT_TOUR_NAME → Off-by-one, + // wenn die Standard-Tour an Index 0 lag. Fix: moveTour interpretiert + // Indizes im sichtbaren Raum und merged zurück, wobei die ausgeblendete + // Standard-Tour ihre relative Position behält. Hidden-Standard-Verhalten + // bleibt erhalten; echte Touren sind alle frei sortierbar. + // • Audioplayer: Der etablierte GPS2Audio-Player (ClipDetailSheet/ + // -Fullscreen) erschien beim manuellen Start eines Tracks/Clips nicht + // mehr. Fix: Neuer LaunchedEffect öffnet die Player-Ansicht automatisch + // für den aktiv abgespielten Wegpunkt, sobald Wiedergabe beginnt. + // • Keine Regressionen: Multi-Clip, Clip-Navigation, Countdown, maximierter + // Detailplayer, Hintergrundwiedergabe, PTT-Pause, Tour-Wiedergabe- + // Persistenz, Explorer/MapLibre, Routing, POIs, Backup, Lizenz unberührt. + // v2.7.33 — UI-Politur Begleitmusik-Dialog (rein gestalterisch): + // • Begleitmusik fühlt sich jetzt wie eine integrierte Musik-Karte der + // Tour an statt wie ein technisches Formular. + // • Neue Status-/Musik-Karte (MusicStatusCard) im Teal/Dark-Stil: + // Tour-Kontext, Aktiv-Status, Kurz-Zusammenfassung + Aktivieren-Schalter. + // • Kompakte Quellen-Chips (Lokale Playlist / Stream), primäre Aktion + // „Musik auswählen" als gefüllter Button, einladender Empty-State + // (MusicEmptyState) statt technischem Text. + // • Verhaltensoptionen mit Klartext-Titeln („Bei Ansagen pausieren", + // „Leiser im Hintergrund", „Weiterlaufen lassen", „Sanft aus-/einblenden") + // unter zusammengefasstem Abschnitt „Verhalten & Optionen". + // • KEINE Änderung an Datenmodell, Speicherung, Backup/Import, Lizenz, + // Playback-Service, PTT, Atmo, Waypoint-/Hintergrundwiedergabe, MapLibre/ + // Explorer oder Routing. validateAndSave + alle Optionen unverändert. + // • Keine Regression zu v2.7.32 (Player-Auto-Open, Tour-Reihenfolge). + // v2.7.34 — UI-Politur „Wegpunkt bearbeiten" (rein gestalterisch): + // • Ruhigeres Layout: kompakte Status-/Kopfkarte oben (Name, Tour, + // Kurzüberblick Position/Audio). + // • „Details" (Name + Tour) immer sichtbar; Position, Audio, Auslösung, + // Wiedergabe-Regeln, Zeitplan und „Kopieren & duplizieren" als + // einklappbare Abschnitte mit Kurz-Zusammenfassung. + // • Kopieren/Duplizieren nach ganz unten verschoben (nicht mehr zwischen + // Basisfeldern und Position); Koordinaten nur bei Bedarf aufgeklappt. + // • Abschnitte mit Validierungsfehlern öffnen sich automatisch. + // • KEINE Änderung an Datenmodell, Speicherung, Backup/Import, Lizenz, + // Playback, Multi-Clip, PTT, Atmo, MapLibre/Explorer oder Routing. + // Validation + Save (confirmButton) und alle Felder unverändert. + // v2.7.35 — UI-Politur Hauptseite/Audio-Bibliothek (rein gestalterisch): + // • Abschnittstitel „Meine Touren" mit Anzahl als Chip rechts. + // • Tour-Karten: feste Textblock-Höhe (gleichmäßige Karten), klare + // Metazeile statt abgeschnittenem Text („Route ge…"). + // • Leere Touren wirken bewusst („Noch leer") statt „0 Wegpunkte". + // • Chips immer sichtbar: Wegpunkt-Zahl bzw. „Noch leer" + „Route" / + // „Keine Route". + // • Live/PTT-Modul optisch zurückgenommen (Einrückung/kompakter) – + // KEINE Verhaltensänderung an LivePttCard. + // • KEINE Änderung an Datenmodell, Speicherung, Backup/Import, Lizenz, + // Playback, Multi-Clip, PTT/Atmo-Verhalten, MapLibre/Explorer, Routing, + // Tour-Reihenfolge oder Player-Auto-Open. + // v2.7.36 — Bugfix: Absturz/ANR nach Backup-Erstellung. + // • Ursache: exportBackupZip() führte Flow-Reads, ZIP-Aufbau und das + // Kopieren aller Mediendateien im viewModelScope auf dem Main-Thread + // aus → ANR ("App reagiert nicht"), obwohl die Datei geschrieben war. + // • Fix: schwere IO läuft jetzt auf Dispatchers.IO (wie der Import-Pfad); + // State-Updates für Erfolg/Fehler bleiben auf Main. Zusätzliche + // Absicherung: unerwartete Fehler werden als nicht-fatale Meldung + // gezeigt statt abzustürzen. + // • KEINE Änderung am Backup-Format/-Inhalt, an der Import-Logik oder an + // anderen Funktionen. + // v2.7.37 — Hörbuch-Gefühl: kurze Stille zwischen Clips/Wegpunkten. + // • Bei der manuellen Sequenz-Wiedergabe (Tour-Play) wird vor dem + // automatischen Start des nächsten Eintrags eine feste Pause von + // 1,7 s eingefügt – sowohl Clip→Clip als auch Wegpunkt→Wegpunkt. + // • Kein UI-Setting, keine Persistenz, kein neues Datenmodell. + // • Gilt NUR für die manuelle Sequenz; NICHT für GPS-getriggerte + // Wiedergabe, PTT, Atmo oder die Einzelclip-Vorschau. + // • Die Pause ist als abbrechbarer Coroutine-Job umgesetzt: Next/Skip + // überspringt sie sofort, Pause/Stop/Tour-Wechsel brechen sie ab – + // kein verspäteter Auto-Start nach dem Stoppen. + // • KEINE Änderung an Backup/Import, Lizenz, Hintergrund-Playback oder + // am Player-Auto-Open. + // + // v2.7.38 — Audio-Zuverlässigkeit (Regressionsfix, insb. Samsung-Tablets): + // • Die v2.7.37-Stille (Auto-Advance-Gap) bleibt erhalten, ist aber jetzt + // STRIKT auf den Auto-Advance nach einem natürlich beendeten Clip + // innerhalb DERSELBEN Tour beschränkt. Der verzögerte Gap-Job prüft vor + // dem Folge-Start, dass weder die Tour gewechselt wurde noch der Nutzer + // pausiert/gestoppt hat – sonst wird er verworfen. + // • Tour-Wechsel (selectTour) und Tour-Löschen (deleteTour) brechen einen + // anstehenden Gap-Job ab, damit kein verspäteter Start in der falschen + // bzw. gelöschten Tour feuert. Aktive Wiedergabe-Persistenz bleibt. + // • ManualAudioPlayer meldet Lade-/Prepare-/Start- und asynchrone + // MediaPlayer-Fehler jetzt über onError zurück. Schlägt der Start fehl + // (z. B. Samsung-SAF-/Codec-Problem), wird der "spielt"-Zustand sauber + // zurückgesetzt und eine Fehlermeldung angezeigt – statt still ohne Ton + // auf "spielt" zu verharren. + // • Erster manueller Start wird nie durch einen Gap-Job verzögert + // (unverändert) und kann nicht von einem alten Gap-Job überschrieben + // werden. + // • Keine Änderung an GPS-Wiedergabe, PTT, Atmo, Einzelclip-Vorschau, + // Hintergrund-Playback, Backup/Import, Lizenz oder Player-Auto-Open. + versionCode = 144 + versionName = "2.7.38" } val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE") diff --git a/app/src/main/kotlin/de/waypointaudio/MainActivity.kt b/app/src/main/kotlin/de/waypointaudio/MainActivity.kt index 23f3c47..5356591 100644 --- a/app/src/main/kotlin/de/waypointaudio/MainActivity.kt +++ b/app/src/main/kotlin/de/waypointaudio/MainActivity.kt @@ -165,15 +165,18 @@ class MainActivity : ComponentActivity() { // direkt die bestehende Tour-/Waypoint-Ansicht. navController.navigate("list") }, - // v2.5.34 — Explorer-Kontext-Fix: - // Einstieg aus Audio-Bibliothek/Home muss immer neutral - // öffnen. selectDefaultTour() setzt selectedTour auf - // DEFAULT_TOUR_NAME, bevor der Explorer gerendert wird. - // Einstieg aus WaypointListScreen (Tour-Ansicht) behält - // die jeweils aktive Tour — dort wird selectDefaultTour() - // nicht aufgerufen. + // v2.7.14 — Explorer-Kontext-Fix (neutral): + // Einstieg aus Audio-Bibliothek/Home öffnet immer einen + // leeren, neutralen Kontext ("Unbenannte Tour"). + // selectNeutralTourForNewExplorer() setzt selectedTour auf + // DEFAULT_TOUR_NAME und aktiviert den Neutral-Explorer-Flag, + // der verhindert, dass der combine-Kollektor automatisch + // zur ersten echten Tour wechselt. + // Einstieg aus WaypointListScreen (Touransicht) behält + // die jeweils aktive Tour — dort wird keine tour-Reset-Funktion + // aufgerufen. onOpenExplorer = { - viewModel.selectDefaultTour() + viewModel.selectNeutralTourForNewExplorer() navController.navigate("explorer") }, // v2.5.19 — „Track Editor" wurde aus dem Hauptmenü diff --git a/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt index e298ab7..0afb257 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt @@ -113,7 +113,24 @@ data class PoiLayerSettings( ) /** - * Gespeicherte Karten-Stil-Einstellungen — v2.5.38. + * Persistierte Layer-Overlay-Einstellungen — v2.7.26. + * Alle Overlay-Toggles aus dem MapLayerPanel, die bisher nur im + * Compose-State lebten und bei App-Neustart zurückgesetzt wurden. + * + * @param showMarkers Wegpunkt-Marker anzeigen. + * @param showRadius Audio-Radius/Reichweite anzeigen. + * @param showTrack GPS-Track/Draft-Linien anzeigen. + * @param autoPoiLoadEnabled POIs automatisch nachladen (bei Kamerabewegung). + */ +data class MapLayerOverlaySettings( + val showMarkers: Boolean = true, + val showRadius: Boolean = true, + val showTrack: Boolean = true, + val autoPoiLoadEnabled: Boolean = true, +) + +/** + * Gespeicherte Karten-Stil-Einstellungen — v2.5.38 / v2.7.26. * * @param baseStyle gewählter Basis-Stil (wird persistent gespeichert) * @param showAudioLength ob die visuelle Audio-/Trigger-Länge als @@ -122,6 +139,7 @@ data class PoiLayerSettings( * @param showAudioLines Clip-Längen als projizierte Liniensegmente. * @param routingEndpoint optionaler OSRM-Routing-Endpoint. * @param poiLayers POI-Kategorien-Sichtbarkeit (persistent). + * @param overlayLayers Weitere Layer-Toggles (Marker, Radius, Track, Auto-POI-Load). * * Legacy-Felder (customSatelliteUrl, customTopoUrl, customTerrainDemUrl) werden * weiterhin gelesen, aber nicht mehr in der UI angeboten — für Rückwärts- @@ -137,6 +155,8 @@ data class MapStyleSettings( val showAudioLines: Boolean = true, val routingEndpoint: String = "", val poiLayers: PoiLayerSettings = PoiLayerSettings(), + // v2.7.26 — Persistente Layer-Overlay-Einstellungen + val overlayLayers: MapLayerOverlaySettings = MapLayerOverlaySettings(), ) private val Context.mapStyleDataStore by preferencesDataStore(name = "map_style_settings") @@ -159,6 +179,11 @@ class MapStyleStore(private val context: Context) { val KEY_POI_MOBILITY = booleanPreferencesKey("poi_mobility") // v2.5.42 val KEY_POI_SHOW_NAMES = booleanPreferencesKey("poi_show_names") + // v2.7.26 — Layer-Overlay-Persistenz + val KEY_SHOW_MARKERS = booleanPreferencesKey("show_markers") + val KEY_SHOW_RADIUS = booleanPreferencesKey("show_radius") + val KEY_SHOW_TRACK = booleanPreferencesKey("show_track") + val KEY_AUTO_POI_LOAD = booleanPreferencesKey("auto_poi_load") } val settings: Flow = context.mapStyleDataStore.data.map { prefs -> @@ -179,6 +204,13 @@ class MapStyleStore(private val context: Context) { showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true, showMobility = prefs[KEY_POI_MOBILITY] ?: true, showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false, + ), + // v2.7.26 — Layer-Overlay-Persistenz + overlayLayers = MapLayerOverlaySettings( + showMarkers = prefs[KEY_SHOW_MARKERS] ?: true, + showRadius = prefs[KEY_SHOW_RADIUS] ?: true, + showTrack = prefs[KEY_SHOW_TRACK] ?: true, + autoPoiLoadEnabled = prefs[KEY_AUTO_POI_LOAD] ?: true, ) ) } @@ -212,6 +244,16 @@ class MapStyleStore(private val context: Context) { } } + // v2.7.26 — Layer-Overlay-Persistenz + suspend fun setOverlayLayers(overlay: MapLayerOverlaySettings) { + context.mapStyleDataStore.edit { + it[KEY_SHOW_MARKERS] = overlay.showMarkers + it[KEY_SHOW_RADIUS] = overlay.showRadius + it[KEY_SHOW_TRACK] = overlay.showTrack + it[KEY_AUTO_POI_LOAD] = overlay.autoPoiLoadEnabled + } + } + // v2.5.37 — Legacy-Stubs für Aufrufstellen aus v2.4.x die // noch customSatelliteUrl / customTopoUrl / customTerrainDemUrl schreiben. // Diese Werte werden weiterhin gelesen (Preferences bleiben), diff --git a/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt index 9277416..4e3ba29 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt @@ -175,8 +175,45 @@ class WaypointStore(private val context: Context) { } } - /** Speichert die gesamte Wegpunktliste. */ + /** + * Speichert die gesamte Wegpunktliste. + * + * v2.7.21 — Waypoint-Preservation-Guard: + * Wenn [waypoints] leer ist, aber im DataStore bereits Wegpunkte vorhanden + * sind, wird der Schreibvorgang NICHT ausgeführt. Das verhindert, dass eine + * versehentlich leere oder veraltete In-Memory-Liste (`_waypoints.value` + * aus einem Race-Condition-Fenster) bestehende Wegpunkte überschreibt. + * + * Explizites Leeren (Tour-Löschung, Migration) muss [saveAllForced] verwenden. + */ suspend fun saveAll(waypoints: List) { + context.dataStore.edit { prefs -> + // v2.7.21 — Preservation Guard: nie eine leere Liste über eine + // nicht-leere Liste schreiben. Schutz gegen Race-Condition-Verlust. + if (waypoints.isEmpty()) { + val existingJson = prefs[KEY_WAYPOINTS] ?: "[]" + val existingList = runCatching { + gson.fromJson>(existingJson, waypointListType) + }.getOrNull() + if (!existingList.isNullOrEmpty()) { + // Vorhandene Wegpunkte schützen — leere Liste wird NICHT geschrieben. + android.util.Log.w( + "WaypointStore", + "saveAll() mit leerer Liste blockiert: ${existingList.size} bestehende Wegpunkte bleiben erhalten." + ) + return@edit + } + } + prefs[KEY_WAYPOINTS] = gson.toJson(waypoints) + } + } + + /** + * v2.7.21 — Forciertes Speichern ohne Preservation-Guard. + * Nur für explizite Lösch- und Migrationsoperationen verwenden, + * bei denen eine leere Liste das gewünschte Ergebnis ist. + */ + suspend fun saveAllForced(waypoints: List) { context.dataStore.edit { prefs -> prefs[KEY_WAYPOINTS] = gson.toJson(waypoints) } @@ -228,6 +265,12 @@ class WaypointStore(private val context: Context) { * v2.4.6 — Speichert (oder ersetzt) die gespeicherte Route einer Tour. * Setzt [TourRoute.updatedAtMillis] auf die aktuelle Zeit, sofern das * übergebene Objekt nicht selbst einen neueren Zeitstempel mitbringt. + * + * v2.7.21 — Waypoint-Preservation-Invariante: + * Diese Funktion schreibt AUSSCHLIESSLICH in [KEY_TOUR_ROUTES]. + * [KEY_WAYPOINTS] (Audio-Wegpunkte) wird durch diesen Aufruf NIEMALS + * verändert. Tourrouten und Audio-Wegpunkte sind vollständig getrennte + * Speicherbereiche. */ suspend fun saveTourRoute(tourName: String, route: TourRoute) { context.dataStore.edit { prefs -> diff --git a/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt index 255124c..c48e77f 100644 --- a/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt +++ b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt @@ -73,9 +73,16 @@ class WaypointRepository(context: Context) { /** Wegpunkt löschen. */ suspend fun delete(id: String) = store.delete(id) - /** Alle Wegpunkte ersetzen. */ + /** Alle Wegpunkte ersetzen (mit Preservation-Guard gegen leere Liste). */ suspend fun saveAll(waypoints: List) = store.saveAll(waypoints) + /** + * v2.7.21 — Alle Wegpunkte forciert ersetzen (kein Preservation-Guard). + * Nur für explizite Lösch-/Migrations-Operationen (z. B. Tour löschen mit + * Reassignment auf DEFAULT_TOUR_NAME, Backup-Restore-Vollersatz). + */ + suspend fun saveAllForced(waypoints: List) = store.saveAllForced(waypoints) + /** Tourliste persistieren. */ suspend fun saveTours(tours: List) = store.saveTours(tours) diff --git a/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt index 2f96146..fe7f653 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt @@ -82,10 +82,12 @@ class ManualAudioPlayer { fun play( context: Context, soundUri: String, + onError: () -> Unit = {}, onCompletion: () -> Unit = {} ) { if (soundUri.isBlank()) { Log.w(TAG, "Keine Audiodatei angegeben.") + onError() return } @@ -100,14 +102,14 @@ class ManualAudioPlayer { }.onFailure { e -> Log.e(TAG, "Fehler beim Fortsetzen von $soundUri", e) releasePlayer() - loadAndPlay(context, soundUri, onCompletion) + loadAndPlay(context, soundUri, onCompletion, onError) } return } // New or different track → fresh load releasePlayer() - loadAndPlay(context, soundUri, onCompletion) + loadAndPlay(context, soundUri, onCompletion, onError) } // ------------------------------------------------------------------------- @@ -168,12 +170,12 @@ class ManualAudioPlayer { ) { isSingleMode = true singleWaypointId = waypointId - loadAndPlay(context, soundUri) { + loadAndPlay(context, soundUri, onCompletion = { // Natural completion: clear single state, then notify caller isSingleMode = false singleWaypointId = null onCompletion() - } + }) } // ------------------------------------------------------------------------- @@ -183,7 +185,8 @@ class ManualAudioPlayer { private fun loadAndPlay( context: Context, soundUri: String, - onCompletion: () -> Unit + onCompletion: () -> Unit, + onError: () -> Unit = {} ) { runCatching { val uri = Uri.parse(soundUri) @@ -200,9 +203,14 @@ class ManualAudioPlayer { onCompletion() } setOnErrorListener { mp, what, extra -> + // v2.7.38 — Asynchroner MediaPlayer-Fehler (z. B. Samsung- + // spezifische Codec-/SAF-Probleme): Ressourcen freigeben und + // den Aufrufer informieren, damit der "spielt"-Zustand nicht + // ohne Ton hängen bleibt. Log.e(TAG, "MediaPlayer-Fehler: what=$what extra=$extra") mp.release() releasePlayer() + onError() true } } @@ -217,8 +225,14 @@ class ManualAudioPlayer { tour = pendingTour ) }.onFailure { e -> + // v2.7.38 — Synchroner Lade-/Prepare-/Start-Fehler. Auf manchen + // Geräten (insb. Samsung-Tablets) kann prepare()/start() mit + // content://-SAF-URIs fehlschlagen. Bisher blieb der UI-Zustand + // dann fälschlich auf "spielt" ohne Audio. Jetzt: sauber freigeben + // und den Aufrufer benachrichtigen. Log.e(TAG, "Fehler beim Abspielen von $soundUri", e) releasePlayer() + onError() } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt index 4871cb7..043e8ca 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt @@ -152,35 +152,31 @@ fun BegleitmusikDialog( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Tour-Kontext - Text( - stringResource(R.string.music_for_tour, tourName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + // ─── Status-/Musik-Karte (v2.7.33) ─────────────────────────────── + // Integrierte Karte statt technischem Formular-Kopf: Tour-Kontext, + // Aktiv-Status, Kurz-Zusammenfassung und Aktivieren-Schalter in + // einer visuell zusammenhängenden Einheit. + val summaryText = when { + sourceType == MusicSourceType.LOCAL_PLAYLIST && localPlaylist.size == 1 -> + stringResource(R.string.music_summary_local_one) + sourceType == MusicSourceType.LOCAL_PLAYLIST && localPlaylist.isNotEmpty() -> + stringResource(R.string.music_summary_local, localPlaylist.size) + sourceType == MusicSourceType.STREAM_URL && streamPlaylist.size == 1 -> + stringResource(R.string.music_summary_stream_one) + sourceType == MusicSourceType.STREAM_URL && streamPlaylist.isNotEmpty() -> + stringResource(R.string.music_summary_stream, streamPlaylist.size) + else -> stringResource(R.string.music_summary_empty) + } + MusicStatusCard( + tourName = tourName, + enabled = enabled, + summary = summaryText, + onToggle = { enabled = it } ) - // ─── Aktivieren/Deaktivieren ───────────────────────────────────── - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - stringResource(R.string.music_enable), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Switch(checked = enabled, onCheckedChange = { enabled = it }) - } - - HorizontalDivider() - if (enabled) { - // ─── Quelltyp ───────────────────────────────────────────────── - Text( - stringResource(R.string.music_source_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) + // ─── Quelle ──────────────────────────────────────────────── + SectionLabel(stringResource(R.string.music_section_source)) Row( verticalAlignment = Alignment.CenterVertically, @@ -190,39 +186,33 @@ fun BegleitmusikDialog( selected = sourceType == MusicSourceType.LOCAL_PLAYLIST, onClick = { sourceType = MusicSourceType.LOCAL_PLAYLIST }, label = { Text(stringResource(R.string.music_source_local)) }, - leadingIcon = if (sourceType == MusicSourceType.LOCAL_PLAYLIST) { - { Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) } - } else null + leadingIcon = { + Icon( + if (sourceType == MusicSourceType.LOCAL_PLAYLIST) + Icons.Filled.Check else Icons.Filled.LibraryMusic, + null, Modifier.size(16.dp) + ) + } ) FilterChip( selected = sourceType == MusicSourceType.STREAM_URL, onClick = { sourceType = MusicSourceType.STREAM_URL }, label = { Text(stringResource(R.string.music_source_stream)) }, - leadingIcon = if (sourceType == MusicSourceType.STREAM_URL) { - { Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) } - } else null + leadingIcon = { + Icon( + if (sourceType == MusicSourceType.STREAM_URL) + Icons.Filled.Check else Icons.Filled.Radio, + null, Modifier.size(16.dp) + ) + } ) } // ─── Lokale Playlist ────────────────────────────────────────── if (sourceType == MusicSourceType.LOCAL_PLAYLIST) { - Text( - stringResource(R.string.music_playlist_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) if (localPlaylist.isEmpty()) { - // Hinweis je nach Verfügbarkeit der Audio-Bibliothek anpassen, - // damit die User wissen, dass sie auch bereits hinzugefügte - // Dateien erneut auswählen können. - Text( - if (audioLibrary.isEmpty()) - stringResource(R.string.music_playlist_empty) - else - stringResource(R.string.music_playlist_empty_with_library), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + // Einladender Empty-State statt technischem Hinweis. + MusicEmptyState() } else { localPlaylist.forEachIndexed { idx, item -> Row( @@ -265,7 +255,8 @@ fun BegleitmusikDialog( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth() ) { - OutlinedButton( + // Primäre Aktion „Musik auswählen" (gefüllter Button). + Button( onClick = { multiPickLauncher.launch( arrayOf("audio/*", "audio/mpeg", "audio/ogg", "audio/flac", "*/*") @@ -325,11 +316,7 @@ fun BegleitmusikDialog( ) if (streamPlaylist.isEmpty()) { - Text( - stringResource(R.string.music_stream_playlist_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + MusicEmptyState() } else { streamPlaylist.forEachIndexed { idx, entry -> val displayLabel = entry.name.ifBlank { entry.url } @@ -597,34 +584,31 @@ fun BegleitmusikDialog( HorizontalDivider() - // ─── Verhalten beim Wegpunkt ────────────────────────────────── - Text( - stringResource(R.string.music_behavior_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) + // ─── Verhalten & Optionen (v2.7.33: zusammengefasster Bereich) ─ + SectionLabel(stringResource(R.string.music_section_advanced)) + // Klartext-Titel, gleiche Funktionen wie zuvor. BehaviorOption( selected = behavior == WaypointMusicBehavior.PAUSE_RESUME, - title = stringResource(R.string.music_behavior_pause_resume), + title = stringResource(R.string.music_behavior_pause_resume_plain), description = stringResource(R.string.music_behavior_pause_resume_desc), onClick = { behavior = WaypointMusicBehavior.PAUSE_RESUME } ) BehaviorOption( selected = behavior == WaypointMusicBehavior.FADE_OUT_IN, - title = stringResource(R.string.music_behavior_fade), + title = stringResource(R.string.music_behavior_fade_plain), description = stringResource(R.string.music_behavior_fade_desc), onClick = { behavior = WaypointMusicBehavior.FADE_OUT_IN } ) BehaviorOption( selected = behavior == WaypointMusicBehavior.DUCK_UNDERLAY, - title = stringResource(R.string.music_behavior_duck), + title = stringResource(R.string.music_behavior_duck_plain), description = stringResource(R.string.music_behavior_duck_desc), onClick = { behavior = WaypointMusicBehavior.DUCK_UNDERLAY } ) BehaviorOption( selected = behavior == WaypointMusicBehavior.CONTINUE_UNDERLAY, - title = stringResource(R.string.music_behavior_continue), + title = stringResource(R.string.music_behavior_continue_plain), description = stringResource(R.string.music_behavior_continue_desc), onClick = { behavior = WaypointMusicBehavior.CONTINUE_UNDERLAY } ) @@ -691,11 +675,7 @@ fun BegleitmusikDialog( HorizontalDivider() // ─── Test-Steuerung ─────────────────────────────────────────── - Text( - stringResource(R.string.music_test_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) + SectionLabel(stringResource(R.string.music_test_label)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilledIconButton( onClick = { viewModel.toggleMusicPlayback() }, @@ -891,3 +871,132 @@ private fun BehaviorOption( } } } + +/** + * v2.7.33 — Integrierte Status-/Musik-Karte (rein gestalterisch). + * Fasst Tour-Kontext, Aktiv-Status, Kurz-Zusammenfassung und den + * Aktivieren-Schalter in einer Karte im GPS2Audio-Teal/Dark-Stil zusammen. + */ +@Composable +private fun MusicStatusCard( + tourName: String, + enabled: Boolean, + summary: String, + onToggle: (Boolean) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = if (enabled) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = if (enabled) Icons.Filled.LibraryMusic else Icons.Filled.MusicNote, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = if (enabled) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.music_card_heading), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (enabled) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource(R.string.music_for_tour, tourName), + style = MaterialTheme.typography.bodySmall, + color = if (enabled) + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + else + MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(2.dp)) + Text( + text = if (enabled) summary else stringResource(R.string.music_status_off), + style = MaterialTheme.typography.labelMedium, + color = if (enabled) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Switch(checked = enabled, onCheckedChange = onToggle) + } + } +} + +/** + * v2.7.33 — Einladender Empty-State, wenn noch keine Musik gewählt wurde. + * Ersetzt den technischen „Noch keine Audiodateien"-Hinweis. + */ +@Composable +private fun MusicEmptyState() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.MusicNote, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + stringResource(R.string.music_empty_state_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(2.dp)) + Text( + stringResource(R.string.music_empty_state_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** v2.7.33 — Einheitliche Abschnittsüberschrift im Teal-Akzent. */ +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt index 6e1bf4f..107df7a 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt @@ -192,15 +192,19 @@ fun ExplorerScreen( val selectedProvider by providerStore.provider.collectAsState(initial = MapProvider.DEFAULT) val settingsStore = remember { ExplorerSettingsStore(context.applicationContext) } - val persistedSettings by settingsStore.settings.collectAsState( - initial = de.waypointaudio.data.ExplorerSettings() + // v2.7.27 — Fix: null initial damit followLocation Restore erst nach echtem DataStore-Emit. + val persistedSettingsOrNull by settingsStore.settings.collectAsState( + initial = null ) + val persistedSettings = persistedSettingsOrNull ?: de.waypointaudio.data.ExplorerSettings() // v2.3.0 — MapLibre/Overlays: persistente Karten-Stil-Einstellungen. val mapStyleStore = remember { MapStyleStore(context.applicationContext) } - val mapStyleSettings by mapStyleStore.settings.collectAsState( - initial = MapStyleSettings() + // v2.7.27 — Fix: null als Initial-Wert, damit Restore erst nach echtem DataStore-Emit feuert. + val mapStyleSettingsOrNull by mapStyleStore.settings.collectAsState( + initial = null ) + val mapStyleSettings = mapStyleSettingsOrNull ?: MapStyleSettings() val scope = rememberCoroutineScope() // Audio-Dauer-Cache je Wegpunkt (URI → Millisekunden). @@ -239,13 +243,13 @@ fun ExplorerScreen( ) } - // Persistente Nachführungs-Einstellung. Erstinitialisierung aus dem DataStore, - // danach: manuelle Map-Bewegung darf das Flag NICHT automatisch zurücksetzen. - var followLocation by remember { mutableStateOf(persistedSettings.followLocation) } + // v2.7.27 — followLocation startet mit true (default); echter Wert aus DataStore via null-safe Emit. + var followLocation by remember { mutableStateOf(true) } var followInitialized by remember { mutableStateOf(false) } - LaunchedEffect(persistedSettings.followLocation) { + LaunchedEffect(persistedSettingsOrNull) { + val loaded = persistedSettingsOrNull ?: return@LaunchedEffect if (!followInitialized) { - followLocation = persistedSettings.followLocation + followLocation = loaded.followLocation followInitialized = true } } @@ -265,14 +269,18 @@ fun ExplorerScreen( var layerState by remember { mutableStateOf(MapLayerState(followLocation = persistedSettings.followLocation)) } - // Initial-Sync der Stil-Einstellungen (einmalig, danach trägt onStateChange). + // v2.7.27 — styleHydrated: true sobald echter DataStore-Wert (nicht null) verarbeitet wurde. var styleHydrated by remember { mutableStateOf(false) } - LaunchedEffect(mapStyleSettings) { + LaunchedEffect(mapStyleSettingsOrNull) { + val loaded = mapStyleSettingsOrNull ?: return@LaunchedEffect if (!styleHydrated) { layerState = layerState.copy( - baseStyle = mapStyleSettings.baseStyle, - showAudioLength = mapStyleSettings.showAudioLength, - showAudioLines = mapStyleSettings.showAudioLines, + baseStyle = loaded.baseStyle, + showAudioLength = loaded.showAudioLength, + showAudioLines = loaded.showAudioLines, + showMarkers = loaded.overlayLayers.showMarkers, + showRadius = loaded.overlayLayers.showRadius, + showTrack = loaded.overlayLayers.showTrack, ) styleHydrated = true } @@ -304,6 +312,25 @@ fun ExplorerScreen( runCatching { mapStyleStore.setShowAudioLines(layerState.showAudioLines) } } } + // v2.7.26 — Layer-Overlay-Toggles persistent speichern (showMarkers, showRadius, showTrack). + LaunchedEffect(layerState.showMarkers, layerState.showRadius, layerState.showTrack) { + if (!styleHydrated) return@LaunchedEffect + val current = mapStyleSettings.overlayLayers + if (layerState.showMarkers != current.showMarkers || + layerState.showRadius != current.showRadius || + layerState.showTrack != current.showTrack) { + runCatching { + mapStyleStore.setOverlayLayers( + de.waypointaudio.data.MapLayerOverlaySettings( + showMarkers = layerState.showMarkers, + showRadius = layerState.showRadius, + showTrack = layerState.showTrack, + autoPoiLoadEnabled = true, // osmdroid-Fallback hat kein autoPoiLoad + ) + ) + } + } + } var showSearchDialog by remember { mutableStateOf(false) } var searchPin by remember { mutableStateOf(null) } @@ -357,6 +384,14 @@ fun ExplorerScreen( // Manager-Track für die neutrale Standardtour nicht als Phantom erscheint. val isNeutralStandard: Boolean = selectedTour == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + + // v2.7.6 — UI-Entkopplung: DEFAULT_TOUR_NAME ("Standard") wird dem Nutzer + // nicht als interner Konstantenname angezeigt. Im neutralen Explorer-Kontext + // erscheint stattdessen "Spontane Tour". Echte Tournamen bleiben unverändert. + val displayTourLabel: String = + if (isNeutralStandard) stringResource(R.string.explorer_spontane_tour) + else selectedTour + val visibleTrack: List = when { isRecording -> currentTrack activeDraft != null -> activeDraft.points @@ -638,7 +673,13 @@ fun ExplorerScreen( prefillTourName = selectedTour, tourDefaults = tourDefaults, onConfirm = { newWaypoint -> - viewModel.upsert(newWaypoint) + // v2.7.7 — Spontane-Tour-Autocreate (auch im OSM-Explorer-Pfad): + // Neuer Wegpunkt im neutralen Kontext → automatisch echte Tour erzeugen. + if (editDialogWaypoint == null) { + viewModel.upsertFromNeutralExplorer(newWaypoint) + } else { + viewModel.upsert(newWaypoint) + } showEditDialog = false editDialogWaypoint = null editDialogPrefill = null @@ -923,7 +964,7 @@ fun ExplorerScreen( MapModeSegmentedHeader( current = currentMode, onChange = onModeChange, - subtitle = selectedTour + subtitle = displayTourLabel ) } else { Column { @@ -937,7 +978,7 @@ fun ExplorerScreen( Text(stringResource(R.string.explorer_title)) } Text( - text = selectedTour, + text = displayTourLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) ) @@ -1616,11 +1657,28 @@ internal fun StoryboardPanel( } } Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.explorer_storyboard_no_routing_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + // v2.6.0 — Erklärtext: Unterschied Tourverlaufspunkte vs. Audio-Wegpunkte + androidx.compose.material3.Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() + ) { + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + stringResource(R.string.explorer_storyboard_explainer), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(2.dp)) + Text( + stringResource(R.string.explorer_storyboard_no_routing_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt index e94da93..429a64c 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt @@ -195,24 +195,47 @@ fun ExplorerScreenMapLibre( val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() // v2.4.6 — Aktive Tourroute (persistente Route der ausgewählten Tour). val activeTourRoute by viewModel.activeTourRoute.collectAsState() + // v2.6.2 — Session-Route: zuletzt berechnete/geplante Route der aktuellen Tour. + // Überlebt Workspace-Wechsel (lebt im ViewModel), wird beim Tourwechsel geleert. + val currentSessionRoute by viewModel.currentSessionRoute.collectAsState() + // v2.7.2 — Session-Plan: Start/Via/Ziel des RoutePlanners, überlebt Workspace-Wechsel. + val currentSessionPlan by viewModel.currentSessionPlan.collectAsState() val waypoints = allWaypoints.filter { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour } + // v2.7.11 — Stabiler Render-Key für Waypoint-Marker: + // Berechnet aus ID, Latitude, Longitude und Anzahl der Wegpunkte. + // Dient als expliziter LaunchedEffect-Key, der garantiert feuert, + // wenn sich der Inhalt der Waypoint-Liste ändert — unabhängig davon, + // ob Kotlins List.equals() ein neues Objekt als "strukturell gleich" + // bewertet (z. B. durch Snapshot-Caching in collectAsState). + val waypointRenderKey = waypoints.joinToString(separator = "|") { + "${it.id}:${it.latitude}:${it.longitude}" + } + val primaryColor = MaterialTheme.colorScheme.primary val primaryArgb = primaryColor.toArgb() val tertiaryArgb = MaterialTheme.colorScheme.tertiary.toArgb() val settingsStore = remember { ExplorerSettingsStore(context.applicationContext) } - val persistedSettings by settingsStore.settings.collectAsState( - initial = de.waypointaudio.data.ExplorerSettings() + // v2.7.27 — Fix: null initial damit followLocation Restore erst nach echtem DataStore-Emit. + val persistedSettingsOrNull by settingsStore.settings.collectAsState( + initial = null ) + val persistedSettings = persistedSettingsOrNull ?: de.waypointaudio.data.ExplorerSettings() val mapStyleStore = remember { MapStyleStore(context.applicationContext) } - val mapStyleSettings by mapStyleStore.settings.collectAsState( - initial = MapStyleSettings() + // v2.7.27 — Fix: null als Initial-Wert, damit LaunchedEffect erst nach echtem + // DataStore-Emit (nicht sofort mit Default-Objekt) die Restore-Logik auslöst. + // Dadurch wird styleHydrated NICHT durch das Default-Objekt gesetzt, sondern + // erst wenn wirkliche DataStore-Daten ankommen. + val mapStyleSettingsOrNull by mapStyleStore.settings.collectAsState( + initial = null ) + // Nicht-nullable Alias für Stellen, die keinen Restore benötigen (nur Lesen). + val mapStyleSettings = mapStyleSettingsOrNull ?: MapStyleSettings() val scope = rememberCoroutineScope() // Audio-Dauer-Cache je Wegpunkt-URI. @@ -231,6 +254,8 @@ fun ExplorerScreenMapLibre( var showRoutingInfo by remember { mutableStateOf(false) } var showRoutePlanner by remember { mutableStateOf(false) } + // v2.7.0 — Tourverlauf-Editor-Sheet + var showTourverlaufEditor by remember { mutableStateOf(false) } var routeLine by remember { mutableStateOf>(emptyList()) } var routeStatus by remember { mutableStateOf(null) } var routeLoading by remember { mutableStateOf(false) } @@ -249,6 +274,13 @@ fun ExplorerScreenMapLibre( val isNeutralStandard: Boolean = selectedTour == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + // v2.7.7 — UI-Entkopplung (MapLibre-Variante): DEFAULT_TOUR_NAME ("Standard") wird + // dem Nutzer nicht als interner Konstantenname angezeigt. Im neutralen Explorer-Kontext + // erscheint stattdessen "Spontane Tour". Echte Tournamen bleiben unverändert. + val displayTourLabel: String = + if (isNeutralStandard) stringResource(R.string.explorer_spontane_tour) + else selectedTour + // v2.4.6 — Gespeicherte Tourroute als sekundäre Quelle für Clip-Linien // und als Visualisierung, wenn keine aktuelle Route/Planlinie aktiv ist. // v2.5.3 — Manuelle Routen werden separat (als manualRoutePolylines) @@ -257,30 +289,133 @@ fun ExplorerScreenMapLibre( // v2.5.35 — Im neutralen Standard-Kontext (isNeutralStandard + keine Aufzeichnung // + kein Draft) wird savedRouteLine explizit leer gehalten, damit keine alte // persistierte TourRoute als Phantom-Linie erscheint. Daten werden NICHT gelöscht. + // v2.7.4 — PLANLINE-Routen sind explizit vom Nutzer für diese Tour gespeichert + // und dürfen auch auf der Standardtour angezeigt werden (kein Phantom-Schutz nötig). + // Phantom-Guard gilt weiterhin für OSRM/TRACK/IMPORTED-Quellen auf der neutralen + // Standardtour, damit keine zufälligen alten Routen erscheinen. val savedRouteLine: List = remember(activeTourRoute, isNeutralStandard, isRecording) { val r = activeTourRoute - if (r == null || r.source == de.waypointaudio.data.RouteSourceKind.MANUAL) emptyList() - // Phantom-Guard: Für die Standardtour keine gespeicherte Route zeigen, - // solange keine Aufzeichnung läuft (aktiver Draft wird unten behandelt). + if (r == null) emptyList() + // v2.7.4 — PLANLINE explizit ausgenommen: Planlinien sind nutzerseitig + // bewusst gesetzt und sollen auf der Standardtour immer sichtbar sein. + else if (r.source == de.waypointaudio.data.RouteSourceKind.PLANLINE) + r.polyline.map { LatLng(it.lat, it.lon) } + // v2.7.22 — MANUAL-Routen werden wie OSRM-Routen via savedRouteLine + // als grüne Tourlinie gerendert (hasSavedRoute-Pfad). Das doppelte + // Zeichnen wird durch den manualRouteMode-Guard in der manuellen + // Render-Schicht verhindert (manualRoutePolylines nur bei mode=true). + // Phantom-Guard gilt für MANUAL auf der Standardtour weiterhin. + else if (r.source == de.waypointaudio.data.RouteSourceKind.MANUAL) { + if (isNeutralStandard && !isRecording) emptyList() + else r.polyline.map { LatLng(it.lat, it.lon) } + } + // Phantom-Guard: Alle anderen Quellen (OSRM, TRACK, IMPORTED) auf der + // Standardtour ohne laufende Aufzeichnung unterdrücken. else if (isNeutralStandard && !isRecording) emptyList() else r.polyline.map { LatLng(it.lat, it.lon) } } - // Beim Wechsel der ausgewählten Tour: Plan und berechnete Route verwerfen, + // v2.6.2 — Beim Wechsel der ausgewählten Tour: Plan und berechnete Route verwerfen, // damit der Routenplaner nicht eine fremde Route der Vortour zeigt. - // - // v2.5.2 — Wenn die neue Tour eine gespeicherte TourRoute hat, wird deren - // Profil als Default in den frischen Plan übernommen. So bleibt das - // Fortbewegungsmittel beim Neuberechnen konsistent zur gespeicherten Route. - LaunchedEffect(selectedTour, activeTourRoute?.id) { - val storedProfile = RoutingProfile.fromKey(activeTourRoute?.profile) - routePlan = RoutePlan(profile = storedProfile) + // WICHTIG: activeTourRoute?.id wurde BEWUSST aus den Keys entfernt (v2.6.2-Fix). + // Früher löste das automatische Speichern der Session-Route einen activeTourRoute- + // Wechsel aus, der sofort wieder routeLine leerte. Jetzt wird der Reset nur noch + // beim Tourwechsel (selectedTour) ausgelöst. Das Profil-Update erfolgt separat. + LaunchedEffect(selectedTour) { + // v2.7.2 — Kein blindes Reset auf RoutePlan(): erst prüfen, ob ein + // Session-Plan für diese Tour gespeichert ist, und ihn dann wiederherstellen. + // Profil wird im nachfolgenden LaunchedEffect gesetzt, falls Plan leer. + val savedPlan = currentSessionPlan + if (savedPlan != null && savedPlan.first == selectedTour) { + routePlan = savedPlan.second + } else { + routePlan = RoutePlan() // Profil wird im nächsten LaunchedEffect gesetzt + } routeLine = emptyList() routeStatus = null lastComputedDistanceMeters = null lastComputedDurationSeconds = null lastComputedEndpoint = "" } + // v2.5.2/v2.6.2 — Profil der gespeicherten Tourroute in den Plan übernehmen, + // aber OHNE routeLine zu leeren. Nur wirksam wenn Plan noch kein Ziel hat + // (d. h. nicht vom Nutzer manuell befüllt wurde). + LaunchedEffect(activeTourRoute?.profile) { + val storedProfile = RoutingProfile.fromKey(activeTourRoute?.profile) + if (routePlan.start == null && routePlan.destination == null) { + routePlan = routePlan.copy(profile = storedProfile) + } + } + // v2.6.2 — Session-Route wiederherstellen, wenn der Nutzer aus einem anderen + // Workspace zurückkehrt. Bedingungen: + // 1. Es gibt eine gespeicherte Session-Route für die aktuelle Tour. + // 2. routeLine ist leer (kein parallel aktiver Berechnungslauf). + // 3. Die Session-Route enthält Geometrie (mindestens 2 Punkte). + // Falls alle Bedingungen erfüllt: routeLine aus der Session-Route wiederherstellen. + LaunchedEffect(currentSessionRoute, selectedTour) { + val (sessionTour, sessionRoute) = currentSessionRoute ?: return@LaunchedEffect + if (sessionTour != selectedTour) return@LaunchedEffect + if (routeLine.size >= 2) return@LaunchedEffect + if (sessionRoute.polyline.size < 2) return@LaunchedEffect + // v2.7.9 — PLANLINE-Session-Routen überspringen: Diese werden vom Zeichnungseffekt + // direkt über savedRouteLine/routePlanline in orange gezeichnet. Das Laden in + // routeLine würde sie blau als berechnete OSRM-Route darstellen. + if (sessionRoute.source == de.waypointaudio.data.RouteSourceKind.PLANLINE) return@LaunchedEffect + routeLine = sessionRoute.polyline.map { LatLng(it.lat, it.lon) } + // Metadaten ebenfalls wiederherstellen, damit "Tourverlauf speichern" wieder + // korrekte Werte übergibt. + lastComputedDistanceMeters = sessionRoute.distanceMeters + lastComputedDurationSeconds = sessionRoute.durationSeconds + lastComputedEndpoint = sessionRoute.endpoint + } + // v2.7.2 — Session-Plan wiederherstellen, wenn der Nutzer aus einem anderen + // Workspace zurückkehrt und kein Plan gesetzt ist. + // Bedingungen: Session-Plan vorhanden, passend zur Tour, routePlan noch leer. + LaunchedEffect(currentSessionPlan, selectedTour) { + val (planTour, savedPlan) = currentSessionPlan ?: return@LaunchedEffect + if (planTour != selectedTour) return@LaunchedEffect + // Nur wiederherstellen, wenn der lokale Plan noch leer ist (kein manuelles + // Update laufend) — verhindert Überschreiben bei laufendem Nutzer-Edit. + if (routePlan.start == null && routePlan.destination == null) { + routePlan = savedPlan + } + } + // v2.7.2 — Plan aus persistierter TourRoute (PLANLINE/OSRM) wiederherstellen, + // wenn weder Session-Plan noch lokaler Plan vorliegen. Das passiert nur beim + // ersten Öffnen nach App-Start (Cold Start), wenn die TourRoute Start/Ziel kennt. + // MANUAL-Routen werden nicht als Plan interpretiert. + LaunchedEffect(activeTourRoute, selectedTour) { + val r = activeTourRoute ?: return@LaunchedEffect + if (r.source == de.waypointaudio.data.RouteSourceKind.MANUAL) return@LaunchedEffect + val rStart = r.start ?: return@LaunchedEffect + val rDest = r.destination ?: return@LaunchedEffect + // Nur wenn kein Plan gesetzt ist (weder Session noch lokal). + if (currentSessionPlan != null) return@LaunchedEffect + if (routePlan.start != null || routePlan.destination != null) return@LaunchedEffect + val restoredPlan = RoutePlan( + start = RouteStop( + latitude = rStart.latitude, longitude = rStart.longitude, + label = rStart.label.ifBlank { "Start" }, + source = RouteStopSource.SEARCH + ), + destination = RouteStop( + latitude = rDest.latitude, longitude = rDest.longitude, + label = rDest.label.ifBlank { "Ziel" }, + source = RouteStopSource.SEARCH + ), + via = r.via.map { vs -> + RouteStop( + latitude = vs.latitude, longitude = vs.longitude, + label = vs.label.ifBlank { "Via" }, + source = RouteStopSource.SEARCH + ) + }, + profile = RoutingProfile.fromKey(r.profile) + ) + routePlan = restoredPlan + // Im ViewModel sichern, damit spätere Workspace-Rückkehr ihn kennt. + viewModel.setCurrentSessionPlan(selectedTour, restoredPlan) + } // Planlinie wird automatisch aus dem Plan abgeleitet — Luftlinie zwischen // Start/Via/Ziel. Sie ersetzt die Route nur, wenn keine echte Route da ist // (kein konfigurierter Endpunkt oder Fehler). @@ -288,12 +423,152 @@ fun ExplorerScreenMapLibre( routePlan.orderedStops().map { LatLng(it.latitude, it.longitude) } .takeIf { it.size >= 2 } ?: emptyList() } + + // v2.7.13 — Stabiler Render-Key für den Route/Planlinien-Overlay-Effekt. + // Berechnet aus activeTourRoute-Attributen (ID, source, Punktanzahl), sowie + // savedRouteLine.size und routePlanline.size. Garantiert, dass der + // Route-Overlay-LaunchedEffect feuert wenn sich Routenänderungen ergeben, + // auch wenn List.equals()-Snapshot-Caching eine Re-Composition verzögert. + // Insbesondere feuert er nach DB-Load von activeTourRoute (PLANLINE), damit + // die orange Planlinie beim initialen Öffnen des Explorers sofort sichtbar ist. + val routeRenderKey: String = run { + val r = activeTourRoute + val routeKey = if (r != null) + "${r.id}|${r.source}|${r.polyline.size}" + else + "none" + "$routeKey|sl=${savedRouteLine.size}|pl=${routePlanline.size}|rl=${routeLine.size}" + } + val routingPoiCtx = LocalContext.current + // v2.7.4 — Auto-Persistenz: Sobald routePlan ≥ 2 Stopps hat (Start+Ziel oder + // Start+Via als Minimum), wird die Planlinie automatisch als PLANLINE-TourRoute + // in die DB gespeichert. Das macht den Button „Planlinie zeigen“ zur blossen + // Kamera-Aktion — die Sichtbarkeit hängt nicht mehr davon ab. + // Bedingung: Kein aktuell berechnetes routeLine (OSRM hat Vorrang). + // Bedingung: Kein OSRM/MANUAL activeTourRoute (würde Nutzerroute überschreiben). + // v2.7.21 — Waypoint-Preservation-Invariante für alle Planlinie/Route-Persistenz-Effekte: + // saveTourRouteFor() und setCurrentSessionRoute() schreiben AUSSCHLIESSLICH in + // KEY_TOUR_ROUTES (separater DataStore-Key). KEY_WAYPOINTS (Audio-Wegpunkte) wird + // durch diese Effekte NIEMALS verändert. Die Planlinie-Auto-Persistenz ist sicher. + LaunchedEffect(routePlanline, selectedTour) { + if (routePlanline.size < 2) return@LaunchedEffect + if (routeLine.size >= 2) return@LaunchedEffect // OSRM hat Vorrang + val current = activeTourRoute + // Nicht auto-speichern, wenn eine OSRM- oder MANUAL-Route die Oberhand hat + if (current != null && + current.source != de.waypointaudio.data.RouteSourceKind.PLANLINE) return@LaunchedEffect + // Planline aufbauen und persistieren + val label = routingPoiCtx.getString(R.string.tour_route_source_planline) + val planlineRoute = buildTourRouteFromState( + plan = routePlan, + routeLine = emptyList(), + routePlanline = routePlanline, + distanceMeters = null, + durationSeconds = null, + endpoint = label, + planlineSourceLabel = label + ) ?: return@LaunchedEffect + // v2.7.8 — Session-Route ebenfalls setzen, damit Workspace-Rückkehr die + // Planlinie wiederherstellt (currentSessionRoute überlebt Navigation im Prozess). + viewModel.setCurrentSessionRoute(selectedTour, planlineRoute) + // v2.7.19 — FIX: Planlinie inkl. Start/Via/Ziel auch in die DB schreiben, + // damit sie nach einem Prozess-Kill (App-Neustart) aus activeTourRoute + // vollständig wiederhergestellt werden kann. Ohne diesen DB-Schreibvorgang + // überlebte der Plan nur in-memory (currentSessionPlan/currentSessionRoute) + // und war nach einem Prozess-Kill verloren. buildTourRouteFromState übergibt + // plan.start/destination/via korrekt in TourRoute.start/.destination/.via. + // v2.7.21 — Diese Funktion berührt KEINE Wegpunkte (KEY_WAYPOINTS). + viewModel.saveTourRouteFor(selectedTour, planlineRoute) + } + + // v2.7.8 — Auto-Render: Wenn eine gespeicherte Route/Planlinie vorhanden ist + // (activeTourRoute mit Geometrie), aber routeLine noch leer ist (kein OSRM-Lauf + // in dieser Session), wird routeLine automatisch aus savedRouteLine initialisiert. + // Dies macht die Linie sofort beim Öffnen des Explorers sichtbar, ohne Button-Druck. + // Bedingung: routeLine leer UND savedRouteLine befüllt UND kein Session-Restore + // bereits ausstehend (currentSessionRoute wird separat verarbeitet). + // Der Effekt läuft bei jedem activeTourRoute-Wechsel neu, um Tourverlaufsdaten + // korrekt zu laden wenn der Nutzer zwischen Touren wechselt. + // v2.7.8 — Auto-Render: Wenn eine gespeicherte Route vorhanden ist, aber routeLine + // noch leer ist, wird routeLine aus savedRouteLine initialisiert (nur für OSRM/TRACK- + // Quellen). PLANLINE-Quellen werden direkt vom Zeichnungseffekt über hasSavedRoute + // gezeichnet (mit korrekter oranger Farbe), ohne routeLine zu befüllen. + // v2.7.9 — PLANLINE explizit ausgenommen: savedRouteLine für PLANLINE-Quellen + // NICHT in routeLine laden, damit das Zeichnen über den hasSavedRoute-Pfad läuft + // (orange Farbe) und nicht als berechnete OSRM-Route (blau) erscheint. + // Der hasSavedRoute-Pfad im Zeichnungseffekt (zeile ~964) ist die einzige Darstellung + // für PLANLINE-Routen nach App-Start — kein routeLine nötig. + LaunchedEffect(savedRouteLine, selectedTour) { + if (savedRouteLine.size < 2) return@LaunchedEffect + // Nur wenn noch keine Route in dieser Session berechnet/geladen wurde. + if (routeLine.size >= 2) return@LaunchedEffect + // v2.7.9 — PLANLINE-Quellen überspringen: Zeichnungseffekt liest savedRouteLine direkt. + // Das garantiert die korrekte orange Darstellung ohne Umweg über routeLine. + if (activeTourRoute?.source == de.waypointaudio.data.RouteSourceKind.PLANLINE) return@LaunchedEffect + // v2.7.22 — MANUAL-Quellen überspringen: wie PLANLINE wird savedRouteLine direkt + // im hasSavedRoute-Pfad des Render-Effekts grün gezeichnet. Laden in routeLine + // würde MANUAL als blaue OSRM-Route darstellen (falsche Farbe/Semantik). + if (activeTourRoute?.source == de.waypointaudio.data.RouteSourceKind.MANUAL) return@LaunchedEffect + // Session-Route hat Vorrang — wird in separatem LaunchedEffect(currentSessionRoute, selectedTour) geladen. + // Wenn Session-Route da ist, nicht überschreiben. + val sessionRouteAvailable = viewModel.currentSessionRoute.value + ?.let { it.first == selectedTour && it.second.polyline.size >= 2 } == true + if (sessionRouteAvailable) return@LaunchedEffect + routeLine = savedRouteLine + } + // MapLibre-spezifische State-Halter var mapView by remember { mutableStateOf(null) } var mapLibreMap by remember { mutableStateOf(null) } var styleReady by remember { mutableStateOf(false) } + // v2.7.12 — Nonce, der nach jedem erfolgreichen Style-Load inkrementiert wird. + // Stellt sicher, dass der Overlay-LaunchedEffect nach Map/Style-Ready ohne + // Benutzerinteraktion feuert, selbst wenn alle anderen Keys stabil bleiben. + var overlayRenderNonce by remember { mutableStateOf(0) } + // v2.7.28 — appliedBaseStyle / appliedPerspectiveTilt: Welcher Stil + Tilt tatsächlich + // zuletzt per ml.setStyle() auf die Karte angewendet wurde. null = noch nichts. + // Dient als Single Source of Truth, um Doppel-Applies zu erkennen: + // Wenn layerState.baseStyle != appliedBaseStyle ODER perspectiveTilt geändert, + // muss setStyle() aufgerufen werden. + var appliedBaseStyle by remember { mutableStateOf(null) } + var appliedPerspectiveTilt by remember { mutableStateOf(null) } + + // v2.7.25 — Initial-Render-Fix: Wenn savedRouteLine nach dem ersten Style-Load + // aus der DB ankommt (activeTourRoute wird asynchron geladen), feuert der + // Overlay-LaunchedEffect möglicherweise nicht mehr, weil sein savedRouteLine-Key + // sich zwar ändert, aber durch Compose-Snapshot-Caching oder einen bereits + // abgelaufenen styleReady/overlayRenderNonce-Zyklus nicht mehr detektiert wird. + // Diese LaunchedEffect-Guard feuert dediziert, wenn savedRouteLine >= 2 Punkte + // hat UND der Style bereit ist — und erhöht den Nonce, damit der Zeichnungseffekt + // garantiert neu startet. Minimale Wirkung: nur wenn Karte und Stil bereit sind + // und eine gespeicherte Route vorliegt. + LaunchedEffect(savedRouteLine.size, styleReady, mapLibreMap) { + if (!styleReady) return@LaunchedEffect + if (mapLibreMap == null) return@LaunchedEffect + if (savedRouteLine.size < 2) return@LaunchedEffect + overlayRenderNonce++ + } + + // v2.7.30 — Zentraler Overlay-Rebuild-Mechanismus: Verzögerter Retry-Nonce. + // Root Cause: Nach setStyle() und overlayRenderNonce++ haben LaunchedEffects + // nur delay(150ms) Zeit. Falls MapLibre-AnnotationManager noch nicht bereit ist, + // scheitern addMarker/addPolyline still. Dieses LaunchedEffect wartet 500ms nach + // jedem Style-Ready und löst einen zweiten Render-Durchlauf aus. So werden + // Waypoints, Planlinie und Live-Standort garantiert gezeichnet, auch wenn der + // erste Versuch zum falschen Zeitpunkt kam. + // Duplikat-Schutz: drawExplorerOverlaysMapLibre leert vor dem Neuzeichnen alle + // alten Annotations — kein Double-Draw. + LaunchedEffect(styleReady, mapLibreMap) { + if (!styleReady) return@LaunchedEffect + if (mapLibreMap == null) return@LaunchedEffect + // Warte 500ms nach Style-Load, dann zweiten Rebuild ausloesen. + // Dieser Effekt feuert bei JEDEM Style-Ready-Wechsel (initial + Stilwechsel). + kotlinx.coroutines.delay(500) + if (!styleReady) return@LaunchedEffect // Style wurde zwischenzeitlich gewechselt + overlayRenderNonce++ + } // Annotation-Caches: wir entfernen/erzeugen pro Update neu, halten aber die // Referenzen zu den eigenen Annotations, um sie gezielt löschen zu können. @@ -328,7 +603,8 @@ fun ExplorerScreenMapLibre( val snackbarHostState = remember { SnackbarHostState() } // v2.5.45 — Auto-POI-Load State - var autoPoiLoadEnabled by remember { mutableStateOf(true) } // standardmäßig aktiviert + // v2.7.26 — Initialisierung aus persistiertem Wert; überschrieben nach styleHydrated. + var autoPoiLoadEnabled by remember { mutableStateOf(true) } // überschrieben beim ersten DataStore-Lesen var poiAutoLoadJob by remember { mutableStateOf(null) } var lastAutoLoadLat by remember { mutableStateOf(null) } var lastAutoLoadLon by remember { mutableStateOf(null) } @@ -346,11 +622,13 @@ fun ExplorerScreenMapLibre( ) } - var followLocation by remember { mutableStateOf(persistedSettings.followLocation) } + // v2.7.27 — followLocation startet mit default true; echter Wert kommt aus DataStore. + var followLocation by remember { mutableStateOf(true) } var followInitialized by remember { mutableStateOf(false) } - LaunchedEffect(persistedSettings.followLocation) { + LaunchedEffect(persistedSettingsOrNull) { + val loaded = persistedSettingsOrNull ?: return@LaunchedEffect if (!followInitialized) { - followLocation = persistedSettings.followLocation + followLocation = loaded.followLocation followInitialized = true } } @@ -373,11 +651,72 @@ fun ExplorerScreenMapLibre( var manualRoutePoints by remember { mutableStateOf>(emptyList()) } var selectedRoutePointIndex by remember { mutableStateOf(null) } var showRoutePointDialog by remember { mutableStateOf(false) } + // v2.7.1 — Guard gegen Race-Condition beim Auto-Save: + // Auto-Save darf die gespeicherte Route erst löschen/überschreiben, + // nachdem die initiale Ladephase aus der DB abgeschlossen ist. + // Ohne diesen Guard kann ein leeres `manualRoutePoints` (Initialwert) + // die persistierte MANUAL-Route auf der DB löschen, bevor der + // LaunchedEffect sie geladen hat. + var manualRouteInitialized by remember { mutableStateOf(false) } - // Beim Tour-Wechsel: bestehende manuelle Route laden. + // Beim Tour-Wechsel: Modus zurücksetzen (selectedTour als einziger Key, + // damit dieser Reset nur beim echten Tour-Wechsel passiert, nicht bei + // jedem Auto-Save-induzierten activeTourRoute-Update). + // v2.7.9 — Erweitert: Alle Waypoint-Edit-States werden bei Tour-Wechsel bereinigt, + // damit alte Wegpunkte der Vorgänger-Tour nicht bearbeitbar bleiben. Das betrifft: + // - tapMode (inkl. MoveWaypoint, PlaceNew, DrawRoutePoint, MoveRoutePoint) + // - actionMenuWaypoint, editDialogWaypoint, showEditDialog, deleteConfirm + // So kann keine alte Waypoint-Referenz aus einer anderen Tour in den Dialog gelangen. + LaunchedEffect(selectedTour) { + if (manualRouteMode) manualRouteMode = false + // v2.7.9 — Alle tapMode-Varianten zurücksetzen, nicht nur Route-Modi. + // Verhindert: MoveWaypoint(oldId) bleibt aktiv nach Tour-Wechsel → würde + // beim nächsten Karten-Tap einen Wegpunkt aus der alten Tour verschieben. + if (tapMode !is TapModeMl.None) { + tapMode = TapModeMl.None + } + // v2.7.9 — Waypoint-Dialog-States bei Tour-Wechsel leeren. + actionMenuWaypoint = null + editDialogWaypoint = null + showEditDialog = false + deleteConfirm = null + editDialogPrefill = null + selectedRoutePointIndex = null + showRoutePointDialog = false + } + + // v2.7.9 — MapLibre-Marker explizit leeren bei Tour-Wechsel: + // Falls ein Recomposition-Zyklus zwischen selectedTour-Änderung und + // waypoints-Update liegt, könnten kurzzeitig alte Marker sichtbar sein. + // Dieser Effekt räumt sofort auf, ohne auf waypoints-Änderung zu warten. + // Die Neuzeichnung erfolgt danach durch LaunchedEffect(waypoints, ...) mit der + // korrekt gefilterten Waypoint-Liste der neuen Tour. + LaunchedEffect(selectedTour) { + val ml = mapLibreMap ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + // Wegpunkt-Marker und Polygone der alten Tour entfernen. + for (m in waypointMarkers) runCatching { ml.removeMarker(m) } + waypointMarkers.clear() + waypointMarkerIdToWaypoint.clear() + for (p in waypointRadiusPolygons) runCatching { ml.removePolygon(p) } + waypointRadiusPolygons.clear() + // Planline- und Routen-Polylines der alten Tour entfernen. + for (pl in routePolylines) runCatching { ml.removePolyline(pl) } + routePolylines.clear() + for (pl in planlinePolylines) runCatching { ml.removePolyline(pl) } + planlinePolylines.clear() + for (m in routeStopMarkers) runCatching { ml.removeMarker(m) } + routeStopMarkers.clear() + } + + // Beim Tour-Wechsel oder Route-Wechsel: gespeicherte manuelle Route laden. // v2.5.35 — Phantom-Guard: Für die Standardtour keine manuelle Route laden, // die aus einem früheren Zeichnen-Vorgang ohne explizite Tour-Auswahl stammt. - LaunchedEffect(selectedTour, activeTourRoute?.id, activeTourRoute?.source) { + // v2.7.1 — activeTourRoute?.id entfernt aus Keys: Auto-Save-induzierte + // activeTourRoute-Updates (gleiches id, gleiche source) lösen keinen Reload aus. + // Nur echter source-Wechsel (MANUAL ↔ OSRM) oder Tour-Wechsel (selectedTour) + // triggern ein Reload. Dadurch bleibt die Linie stabil während der Editor offen ist. + LaunchedEffect(selectedTour, activeTourRoute?.source) { val r = activeTourRoute manualRoutePoints = if ( r?.source == de.waypointaudio.data.RouteSourceKind.MANUAL @@ -385,19 +724,19 @@ fun ExplorerScreenMapLibre( ) { r.polyline.map { LatLng(it.lat, it.lon) } } else emptyList() - // Modus aufheben, damit man nicht versehentlich weiterzeichnet - if (manualRouteMode) manualRouteMode = false - if (tapMode is TapModeMl.DrawRoutePoint || tapMode is TapModeMl.MoveRoutePoint) { - tapMode = TapModeMl.None - } - selectedRoutePointIndex = null - showRoutePointDialog = false + // v2.7.1 — Initialized-Guard setzen, nachdem die DB-Route geladen wurde. + manualRouteInitialized = true } // Auto-Save: jede Änderung der manuellen Route in TourRoute persistieren. // Beim Leeren wird die Tourroute entfernt (nur, wenn sie zuvor MANUAL war — // berechnete Routen bleiben unangetastet). - LaunchedEffect(manualRoutePoints, selectedTour) { + // v2.7.1 — Guard: Auto-Save nur ausführen, wenn die initiale Ladephase + // abgeschlossen ist (manualRouteInitialized = true). Verhindert, dass ein + // leeres manualRoutePoints die persistierte Route auf der DB löscht, + // bevor der Load-Effekt die Punkte aus der DB gesetzt hat. + LaunchedEffect(manualRoutePoints, selectedTour, manualRouteInitialized) { + if (!manualRouteInitialized) return@LaunchedEffect val currentSaved = activeTourRoute val wasManual = currentSaved?.source == de.waypointaudio.data.RouteSourceKind.MANUAL if (manualRoutePoints.isEmpty()) { @@ -426,17 +765,27 @@ fun ExplorerScreenMapLibre( var layerState by remember { mutableStateOf(MapLayerState(followLocation = persistedSettings.followLocation)) } + // v2.7.27 — styleHydrated: true sobald echter DataStore-Wert (nicht null) verarbeitet wurde. + // Verhindert, dass Persist-Effekte (LaunchedEffect auf layerState.*) vor dem initialen + // Restore schreiben und so die gespeicherten Werte mit Defaults überschreiben. var styleHydrated by remember { mutableStateOf(false) } - LaunchedEffect(mapStyleSettings) { + LaunchedEffect(mapStyleSettingsOrNull) { + val loaded = mapStyleSettingsOrNull ?: return@LaunchedEffect + // Nur beim ERSTEN echten DataStore-Emit (styleHydrated=false) Restore durchführen. + // Da mapStyleSettingsOrNull=null beim Start ist, kann dieser Block nicht durch + // das Default-Objekt fälschlicherweise ausgelöst werden. if (!styleHydrated) { - // v2.5.38 — Beim ersten Laden alle persistierten Einstellungen übernehmen, - // inkl. baseStyle (Kartentyp-Persistenz) und poiLayers. + // v2.7.27 — Vollständige Restore-Logik: baseStyle + alle Layer-Toggles + POI + autoLoad. layerState = layerState.copy( - baseStyle = mapStyleSettings.baseStyle, - showAudioLength = mapStyleSettings.showAudioLength, - showAudioLines = mapStyleSettings.showAudioLines, - poiLayers = mapStyleSettings.poiLayers, + baseStyle = loaded.baseStyle, + showAudioLength = loaded.showAudioLength, + showAudioLines = loaded.showAudioLines, + poiLayers = loaded.poiLayers, + showMarkers = loaded.overlayLayers.showMarkers, + showRadius = loaded.overlayLayers.showRadius, + showTrack = loaded.overlayLayers.showTrack, ) + autoPoiLoadEnabled = loaded.overlayLayers.autoPoiLoadEnabled styleHydrated = true } } @@ -476,6 +825,41 @@ fun ExplorerScreenMapLibre( lastAutoLoadCats = null de.waypointaudio.util.ExplorerPoiClient.clearCache() } + // v2.7.26 — Layer-Overlay-Toggles persistent speichern (showMarkers, showRadius, showTrack). + LaunchedEffect(layerState.showMarkers, layerState.showRadius, layerState.showTrack) { + if (!styleHydrated) return@LaunchedEffect + val current = mapStyleSettings.overlayLayers + if (layerState.showMarkers != current.showMarkers || + layerState.showRadius != current.showRadius || + layerState.showTrack != current.showTrack) { + runCatching { + mapStyleStore.setOverlayLayers( + de.waypointaudio.data.MapLayerOverlaySettings( + showMarkers = layerState.showMarkers, + showRadius = layerState.showRadius, + showTrack = layerState.showTrack, + autoPoiLoadEnabled = autoPoiLoadEnabled, + ) + ) + } + } + } + // v2.7.26 — Auto-POI-Load persistent speichern. + LaunchedEffect(autoPoiLoadEnabled) { + if (!styleHydrated) return@LaunchedEffect + if (autoPoiLoadEnabled != mapStyleSettings.overlayLayers.autoPoiLoadEnabled) { + runCatching { + mapStyleStore.setOverlayLayers( + de.waypointaudio.data.MapLayerOverlaySettings( + showMarkers = layerState.showMarkers, + showRadius = layerState.showRadius, + showTrack = layerState.showTrack, + autoPoiLoadEnabled = autoPoiLoadEnabled, + ) + ) + } + } + } // v2.5.38 — POI-Sichtbarkeit auf den geladenen MapLibre-Style anwenden. // Greift auf heuristische Layer-ID-Präfixe des OpenMapTiles-Schemas zurück. // Wenn ein Layer nicht existiert, passiert nichts (kein Crash). @@ -507,7 +891,11 @@ fun ExplorerScreenMapLibre( // dann alte entfernen. So gibt es keine sichtbare Lücke (kein Flackern) beim // Kartennachladen. Außerdem: maximale Anzahl angezeigter Marker auf 280 begrenzt, // benannte und wichtige Subtypen werden priorisiert. - LaunchedEffect(explorerPoiResults, styleReady, showPoiNames) { + // v2.7.29 — overlayRenderNonce als Key: Stellt sicher, dass POI-Marker nach + // jedem Style-Load (setStyle/Kartenstilwechsel) neu gezeichnet werden, ohne auf + // neue POI-Ergebnisse warten zu muessen. Verhindert fehlende POI-Marker nach + // Outdoor/Dunkel/3D-Wechsel. + LaunchedEffect(explorerPoiResults, styleReady, showPoiNames, overlayRenderNonce) { if (!styleReady) return@LaunchedEffect val ml = mapLibreMap ?: return@LaunchedEffect if (explorerPoiResults.isEmpty()) { @@ -635,21 +1023,47 @@ fun ExplorerScreenMapLibre( // CLASSIC nutzt weiterhin den raster-JSON-Weg. // Phantom-Layer-Cleanup vor setStyle: alle Annotations-Caches leeren, // damit nach styleReady=true ein kompletter Rehydration-Durchlauf startet. + // v2.7.25 — styleReady BEWUSST aus den Keys entfernt: styleReady als Key löste + // einen Doppel-Reload aus, da der initiale Style-Load (im AndroidView factory + // Callback) styleReady=true setzte, was diesen Effect erneut triggerte und + // ml.setStyle() ein zweites Mal aufrief. Das wischte bereits gezeichnete + // Linien weg und störte den Initial-Render der Tourlinie. Nur echte + // Style/Tilt-Wechsel sollen einen Reload auslösen. + // v2.7.28 — styleReady + mapLibreMap + appliedBaseStyle als zusätzliche Keys: + // Behebt den Bug: Wenn layerState.baseStyle bereits auf den gespeicherten Wert + // (z.B. OUTDOOR) gesetzt wird BEVOR styleReady=true (factory-Callback noch + // ausstehend), feuert der Effect, gibt aber return zurück. Wenn dann styleReady=true + // wird, ändert sich layerState.baseStyle NICHT mehr → Effect feuert nicht erneut. + // Lösung: styleReady und mapLibreMap als Keys hinzufügen. Guard gegen Doppel-Apply: + // appliedBaseStyle verfolgt, welcher Stil tatsächlich auf die Karte angewendet + // wurde. Nur wenn layerState.baseStyle != appliedBaseStyle wird setStyle() aufgerufen. LaunchedEffect( layerState.baseStyle, mapStyleSettings.perspectiveTilt, - styleReady + styleReady, + mapLibreMap ) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect + // v2.7.28 — Doppel-Apply-Schutz: Wurde dieser Stil + Tilt bereits angewendet? + // Verhindert, dass der initiale Style-Load (factory) + Style-Change-Effect + // denselben Stil zweimal setzen und Overlays wegwischen. + // Für 3D-Stile muss auch ein Tilt-Änderung einen Reload auslösen. + val currentTilt = if (layerState.baseStyle == MapBaseStyle.CITY_3D || + layerState.baseStyle == MapBaseStyle.TERRAIN_3D || + layerState.baseStyle == MapBaseStyle.PERSPECTIVE) + mapStyleSettings.perspectiveTilt else 0 + if (layerState.baseStyle == appliedBaseStyle && currentTilt == (appliedPerspectiveTilt ?: 0)) return@LaunchedEffect // Phantom-Layer-Cleanup vor Stilwechsel. styleReady = false + // v2.7.30 — planlinePolylines + routeStopMarkers ebenfalls leeren (v2.7.29-Lücke). clearAllAnnotationsCaches( waypointMarkers, waypointMarkerIdToWaypoint, waypointRadiusPolygons, trackMarkers, trackMarkerIdToIndex, trackPolylines, routePolylines, audioLinePolylines, - manualRoutePolylines, manualRouteMarkers, manualRouteMarkerIdToIndex + manualRoutePolylines, manualRouteMarkers, manualRouteMarkerIdToIndex, + planlinePolylines, routeStopMarkers ) myLocationMarker = null accuracyPolygon = null @@ -661,17 +1075,22 @@ fun ExplorerScreenMapLibre( mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0) } else 0.0 + val targetStyle = layerState.baseStyle + val targetTilt = if (targetStyle == MapBaseStyle.CITY_3D || + targetStyle == MapBaseStyle.TERRAIN_3D || + targetStyle == MapBaseStyle.PERSPECTIVE) + mapStyleSettings.perspectiveTilt else 0 val onStyleReady: (Style) -> Unit = { loadedStyle -> // v2.5.37 — 3D Stadt: Gebäude-Extrusion hinzufügen falls building-Layer vorhanden. // Robust: wenn Source/Layer fehlt, kein Crash — einfach 2D-Fallback. - if (layerState.baseStyle == MapBaseStyle.CITY_3D) { + if (targetStyle == MapBaseStyle.CITY_3D) { try { addBuildingExtrusionIfAvailable(loadedStyle) } catch (_: Exception) { /* 2D-Fallback: Layer existiert nicht */ } } // v2.5.38 — POI-Layer-Sichtbarkeit nach Style-Load anwenden. - if (layerState.baseStyle != MapBaseStyle.CLASSIC && - layerState.baseStyle != MapBaseStyle.SATELLITE) { + if (targetStyle != MapBaseStyle.CLASSIC && + targetStyle != MapBaseStyle.SATELLITE) { try { applyPoiLayerVisibility(loadedStyle, layerState.poiLayers) } catch (_: Exception) { /* Kein Crash wenn Layer nicht vorhanden */ } @@ -679,7 +1098,15 @@ fun ExplorerScreenMapLibre( // Kamera-Pitch setzen. val pos = ml.cameraPosition ml.cameraPosition = CameraPosition.Builder(pos).tilt(pitchDeg).build() + // v2.7.28 — appliedBaseStyle + appliedPerspectiveTilt nach erfolgreichem + // setStyle aktualisieren. Verhindert, dass dieser Effekt denselben + // Stil+Tilt erneut anwendet und Overlays wegwischt. + appliedBaseStyle = targetStyle + appliedPerspectiveTilt = targetTilt styleReady = true + // v2.7.12 — Nonce nach Stil-Reload erhöhen, damit Overlay-LaunchedEffect + // auch dann neu feuert, wenn styleReady bereits true war. + overlayRenderNonce++ } when (layerState.baseStyle) { @@ -717,9 +1144,31 @@ fun ExplorerScreenMapLibre( } // Berechnete Routenlinie + Planlinie + Stop-Marker als eigene Polyline-Schicht. - LaunchedEffect(routeLine, routePlanline, routePlan, showRouteOverlay, styleReady, savedRouteLine) { + // v2.7.4 — mapLibreMap als zusätzlicher Key: Nach Map-Neuinitialisierung + // (z. B. Stylewechsel, Workspace-Rückkehr) wird die Planlinie/Route sofort + // neu gezeichnet, auch wenn sich routePlanline/savedRouteLine nicht geändert haben. + // v2.7.8 — activeTourRoute als zusätzlicher Key: Stellt sicher, dass der + // Zeichnungseffekt immer dann feuert, wenn sich die persistierte Touränderung (DB) + // ändert — unabhängig davon ob savedRouteLine strukturell gleich blieb. + // v2.7.13 — routeRenderKey + overlayRenderNonce als zusätzliche Keys: + // • routeRenderKey: Stabiler String-Key aus activeTourRoute-ID/source/pointCount + // + savedRouteLine.size + routePlanline.size. Garantiert Feuer auch wenn + // List.equals()-Snapshot-Caching die Recomposition unterbindet. + // • overlayRenderNonce: Stellt sicher, dass der Effekt nach jedem Style-Load + // (auch initialem) neu feuert. Behebt das Problem, dass die Planlinie + // nicht initial gezeichnet wurde, wenn activeTourRoute nach dem Style-Load + // aus der DB ankam und das addPolyline() still scheiterte weil der + // MapLibre-AnnotationManager noch nicht vollständig initialisiert war. + // • yield() nach styleReady-Guard: Gibt dem MapLibre-AnnotationManager + // Initialisierungszeit nach onStyleLoaded, analog zum Waypoint-Effekt. + LaunchedEffect(routeLine, routePlanline, routePlan, showRouteOverlay, styleReady, savedRouteLine, mapLibreMap, activeTourRoute, routeRenderKey, overlayRenderNonce) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect + // v2.7.30 — delay(150) statt yield(): Gibt MapLibre-AnnotationManager + // ausreichend Zeit nach onStyleLoaded. yield() war zu kurz und ließ + // addPolyline() still fehlschlagen. Analog zur Waypoint-Render-Fix-Logik. + kotlinx.coroutines.delay(150) + if (!styleReady) return@LaunchedEffect // Erneut prüfen nach dem delay for (pl in routePolylines) runCatching { ml.removePolyline(pl) } routePolylines.clear() for (pl in planlinePolylines) runCatching { ml.removePolyline(pl) } @@ -729,101 +1178,93 @@ fun ExplorerScreenMapLibre( if (!showRouteOverlay) return@LaunchedEffect - // v2.4.5 — Planlinie (Luftlinie zwischen Stops) wird NUR gezeichnet, - // wenn keine berechnete Route vorhanden ist. Sobald eine echte - // Routing-Polyline existiert, ist die Planlinie redundant und - // optisch störend (parallel-laufende dünne Linie auf der Route). + // v2.4.5 — Planlinie (Luftlinie zwischen Stops) wird NUR gezeichnet, wenn + // weder eine berechnete Route noch eine gespeicherte Tourroute vorhanden ist. + // Priorität: routeLine > savedRouteLine > routePlanline. + // v2.7.3 — savedRouteLine hat Vorrang vor routePlanline: für OSRM-Routen soll + // die exakte Geometrie aus der DB gezeigt werden, nicht die Luftlinie. Für + // PLANLINE-Routen ist savedRouteLine identisch mit routePlanline, wird aber + // sofort beim App-Start verfügbar (ohne async Plan-Restore), was eine kurze + // weiße Lücke beim Zurückkehren vermeidet. + // v2.7.4 — routePlanline hat Vorrang vor savedRouteLine, wenn: + // (a) Die TourRoute eine PLANLINE-Quelle ist (savedRouteLine = Luftlinie, + // routePlanline ebenfalls, aber routePlanline ist aktueller nach Plan-Änderung) + // (b) routePlanline ≥ 2 Punkte hat (ein gültiger Plan liegt vor) + // Das verhindert, dass eine alte DB-Linie noch sichtbar ist, wenn der User + // gerade neue Stops eingegeben hat (aber der Auto-Save-Effect noch nicht gefeuert hat). val hasComputedRoute = routeLine.size >= 2 - if (!hasComputedRoute && routePlanline.size >= 2) { - val halo = ml.addPolyline( - PolylineOptions() - .addAll(routePlanline) - .color(0xFFFFFFFF.toInt()) - .width(7f) - .alpha(0.7f) - ) - val line = ml.addPolyline( - PolylineOptions() - .addAll(routePlanline) - .color(0xFFFF9800.toInt()) - .width(4f) - .alpha(0.55f) - ) - planlinePolylines += halo - planlinePolylines += line + // v2.7.4: routePlanline hat Priorität über savedRouteLine bei PLANLINE-Quelle + val hasFreshPlanline = routePlanline.size >= 2 && + activeTourRoute?.source == de.waypointaudio.data.RouteSourceKind.PLANLINE + val hasSavedRoute = savedRouteLine.size >= 2 && !hasFreshPlanline + // routePlanline als eigene Linie zeigen, wenn: + // a) kein OSRM routeLine, kein savedRouteLine (ursprüngliches Verhalten) + // b) ODER wenn hasFreshPlanline (PLANLINE-Quelle, routePlanline bevorzugt) + // v2.7.30 — runCatching: Planlinie und Routen robust gegen AnnotationManager-Fehler. + if (!hasComputedRoute && (!hasSavedRoute || hasFreshPlanline) && routePlanline.size >= 2) { + runCatching { ml.addPolyline(PolylineOptions().addAll(routePlanline).color(0xFFFFFFFF.toInt()).width(7f).alpha(0.7f)) } + .getOrNull()?.let { planlinePolylines += it } + runCatching { ml.addPolyline(PolylineOptions().addAll(routePlanline).color(0xFFFF9800.toInt()).width(4f).alpha(0.55f)) } + .getOrNull()?.let { planlinePolylines += it } } // v2.4.6 — Gespeicherte Tourroute zeichnen, wenn keine aktuell berechnete // Route vorhanden ist. Sobald `routeLine` aktiv ist, übernimmt diese, // damit Live-Berechnung > gespeicherte Tourroute (sichtbare Priorität // entspricht der Clip-Linien-Priorität). - if (routeLine.size < 2 && savedRouteLine.size >= 2) { - val halo = ml.addPolyline( - PolylineOptions() - .addAll(savedRouteLine) - .color(0xFFFFFFFF.toInt()) - .width(8f) - .alpha(0.75f) - ) - val line = ml.addPolyline( - PolylineOptions() - .addAll(savedRouteLine) - // Sichtbar unterscheidbar: tieferes Grün für „dauerhaft gespeichert". - .color(0xFF2E7D32.toInt()) - .width(5f) - .alpha(0.9f) - ) - routePolylines += halo - routePolylines += line + // v2.7.3 — savedRouteLine zeigt PLANLINE und OSRM gleichermaßen sofort nach + // App-Start, auch wenn der async Plan-Restore noch nicht abgeschlossen ist. + // v2.7.4 — PLANLINE-Quelle bekommt dieselbe orangene Farbe wie routePlanline, + // damit die Linie vor und nach dem App-Neustart gleich aussieht. Nur + // OSRM/TRACK/IMPORTED bekommen die grüne "gespeichert"-Färbung. + // v2.7.18 — Unified Tour Layers: MANUAL-Quelle bekommt dieselbe primäre + // Tourlinie (grün) wie OSRM — beide sind "Tourlinie", nur durch unterschied- + // liches Werkzeug entstanden. Die violette Bearbeitungs-Vorschau (manualRoute- + // Polylines) erscheint nur noch im aktiven Zeichenmodus (manualRouteMode). + if (!hasComputedRoute && hasSavedRoute) { + val isPlanlineSource = activeTourRoute?.source == de.waypointaudio.data.RouteSourceKind.PLANLINE + // v2.7.18: MANUAL bekommt dieselbe grüne Tourlinie wie OSRM (einheitliches + // Tour-System). PLANLINE bleibt orange/gedimmt als Planungs-Vorschau. + val lineColor = if (isPlanlineSource) 0xFFFF9800.toInt() else 0xFF2E7D32.toInt() + val lineWidth = if (isPlanlineSource) 4f else 5f + val lineAlpha = if (isPlanlineSource) 0.55f else 0.9f + val haloWidth = if (isPlanlineSource) 7f else 8f + val haloAlpha = if (isPlanlineSource) 0.7f else 0.75f + // v2.7.30 — runCatching: savedRouteLine-Polylines robust. + runCatching { ml.addPolyline(PolylineOptions().addAll(savedRouteLine).color(0xFFFFFFFF.toInt()).width(haloWidth).alpha(haloAlpha)) } + .getOrNull()?.let { routePolylines += it } + runCatching { ml.addPolyline(PolylineOptions().addAll(savedRouteLine).color(lineColor).width(lineWidth).alpha(lineAlpha)) } + .getOrNull()?.let { routePolylines += it } } if (routeLine.size >= 2) { - val halo = ml.addPolyline( - PolylineOptions() - .addAll(routeLine) - .color(0xFFFFFFFF.toInt()) - .width(9f) - .alpha(0.85f) - ) - val line = ml.addPolyline( - PolylineOptions() - .addAll(routeLine) - .color(0xFF1976D2.toInt()) - .width(6f) - .alpha(0.95f) - ) - routePolylines += halo - routePolylines += line + // v2.7.30 — runCatching: routeLine-Polylines robust. + runCatching { ml.addPolyline(PolylineOptions().addAll(routeLine).color(0xFFFFFFFF.toInt()).width(9f).alpha(0.85f)) } + .getOrNull()?.let { routePolylines += it } + runCatching { ml.addPolyline(PolylineOptions().addAll(routeLine).color(0xFF1976D2.toInt()).width(6f).alpha(0.95f)) } + .getOrNull()?.let { routePolylines += it } } val iconFactory = IconFactory.getInstance(context) // Stop-Marker für Start/Via/Ziel + // v2.7.30 — runCatching: Stop-Marker robust. routePlan.start?.let { s -> val bm = routeStopBitmap(RouteStopKind.START, context) - routeStopMarkers += ml.addMarker( - MarkerOptions() - .position(LatLng(s.latitude, s.longitude)) - .title("Start: ${s.label}") - .icon(iconFactory.fromBitmap(bm)) - ) + runCatching { + ml.addMarker(MarkerOptions().position(LatLng(s.latitude, s.longitude)).title("Start: ${s.label}").icon(iconFactory.fromBitmap(bm))) + }.getOrNull()?.let { routeStopMarkers += it } } routePlan.via.forEachIndexed { idx, v -> val bm = routeStopBitmap(RouteStopKind.VIA, context, label = (idx + 1).toString()) - routeStopMarkers += ml.addMarker( - MarkerOptions() - .position(LatLng(v.latitude, v.longitude)) - .title("Via ${idx + 1}: ${v.label}") - .icon(iconFactory.fromBitmap(bm)) - ) + runCatching { + ml.addMarker(MarkerOptions().position(LatLng(v.latitude, v.longitude)).title("Via ${idx + 1}: ${v.label}").icon(iconFactory.fromBitmap(bm))) + }.getOrNull()?.let { routeStopMarkers += it } } routePlan.destination?.let { d -> val bm = routeStopBitmap(RouteStopKind.DESTINATION, context) - routeStopMarkers += ml.addMarker( - MarkerOptions() - .position(LatLng(d.latitude, d.longitude)) - .title("Ziel: ${d.label}") - .icon(iconFactory.fromBitmap(bm)) - ) + runCatching { + ml.addMarker(MarkerOptions().position(LatLng(d.latitude, d.longitude)).title("Ziel: ${d.label}").icon(iconFactory.fromBitmap(bm))) + }.getOrNull()?.let { routeStopMarkers += it } } } @@ -831,8 +1272,19 @@ fun ExplorerScreenMapLibre( // Stille Routenpunkte (kleine, dunkle Kreise) + verbindende Linie. // Diese Punkte sind KEINE Wegpunkte und werden niemals in der // Wegpunktliste, Track-Liste oder Storyboard erscheinen. + // v2.7.1 — mapLibreMap als zusätzlicher Key: Wenn die Karte nach einem + // Workspace-Wechsel neu initialisiert wird (mapLibreMap wechselt von null + // auf eine neue Instanz), müssen die Polylines neu gezeichnet werden. + // Ohne diesen Key würde das LaunchedEffect nach dem Map-Ready-Callback + // nicht feuern, da manualRoutePoints sich nicht verändert hat. + // v2.7.18 — Unified Tour Layers: Die violette Bearbeitungs-Vorschaulinie + // wird NUR im aktiven Zeichenmodus (manualRouteMode == true) gezeigt. + // Im gespeicherten Zustand (manualRouteMode == false) übernimmt die grüne + // savedRouteLine die Darstellung — keine Doppellinie mehr. Verlaufspunkt- + // Marker werden weiterhin gezeigt, aber nur im Bearbeitungsmodus (damit + // die Karte im Normalbetrieb nicht mit kleinen Punkten übersät wird). LaunchedEffect(manualRoutePoints, manualRouteMode, styleReady, - selectedRoutePointIndex, primaryArgb) { + selectedRoutePointIndex, primaryArgb, mapLibreMap) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect for (pl in manualRoutePolylines) runCatching { ml.removePolyline(pl) } @@ -841,29 +1293,34 @@ fun ExplorerScreenMapLibre( manualRouteMarkers.clear() manualRouteMarkerIdToIndex.clear() - if (manualRoutePoints.size >= 2) { + // v2.7.18 — Bearbeitungs-Vorschaulinie nur im aktiven Zeichenmodus. + // Im gespeicherten Zustand (mode=false) wird die Tourlinie bereits durch + // savedRouteLine (grün) dargestellt — violette Kopie entfällt. + if (manualRouteMode && manualRoutePoints.size >= 2) { + // Bearbeitungsvorschau: violett, deutlich sichtbar als "wird gerade gezeichnet". val halo = ml.addPolyline( PolylineOptions() .addAll(manualRoutePoints) .color(0xFFFFFFFF.toInt()) - .width(8f) - .alpha(0.75f) + .width(10f) + .alpha(0.80f) ) val line = ml.addPolyline( PolylineOptions() .addAll(manualRoutePoints) - // Klar unterscheidbar von OSRM-Routen (blau) und gespeicherten - // Tourrouten (grün): violett für "manuell gezeichnet". + // Bearbeitungs-Vorschau: violett, klar unterscheidbar von der + // gespeicherten Tourlinie (grün) und OSRM-Route (blau). .color(0xFF6A1B9A.toInt()) - .width(5f) - .alpha(0.95f) + .width(7f) + .alpha(0.97f) ) manualRoutePolylines += halo manualRoutePolylines += line } - // Punkt-Marker pro Routenpunkt – klein, ohne Audio-/Wegpunkt-Bezug. - if (manualRoutePoints.isNotEmpty()) { + // Verlaufspunkt-Marker: nur im aktiven Bearbeitungsmodus als interaktive + // Punkte zeigen. Im Normalbetrieb nicht nötig — reduziert visuelles Rauschen. + if (manualRouteMode && manualRoutePoints.isNotEmpty()) { val iconFactory = IconFactory.getInstance(context) val sizePx = (context.resources.displayMetrics.density * 14).toInt() manualRoutePoints.forEachIndexed { idx, p -> @@ -872,7 +1329,7 @@ fun ExplorerScreenMapLibre( val marker = ml.addMarker( MarkerOptions() .position(p) - .title("Routenpunkt ${idx + 1}") + .title("Verlaufspunkt ${idx + 1}") .icon(iconFactory.fromBitmap(bm)) ) manualRouteMarkers += marker @@ -883,8 +1340,28 @@ fun ExplorerScreenMapLibre( // Haupt-Overlay-Refresh: Wegpunkte, Tracks, Audiozonen, Audio-Längen-Linien, // sowie der Search-Pin. + // v2.7.11 — mapLibreMap und waypointRenderKey als zusätzliche Keys: + // • mapLibreMap: Stellt sicher, dass Marker gezeichnet werden, sobald + // die MapLibre-Instanz verfügbar wird — auch wenn alle anderen Keys + // (waypoints, styleReady) sich nicht geändert haben. + // • waypointRenderKey: Stabiler String-Key aus ID+Lat+Lon aller Waypoints. + // Garantiert, dass der Effekt feuert, wenn ein Waypoint hinzugefügt, + // verschoben oder gelöscht wird — unabhängig von Kotlins List.equals(). + // Verhindert die "N+1"-Verzögerung, bei der der neueste Marker erst + // nach dem nächsten Speichern sichtbar wurde. + // v2.7.12 — overlayRenderNonce als zusätzlicher Key: + // • Wird nach jedem erfolgreichen Style-Load (initial + Stilwechsel) inkrementiert. + // • Stellt sicher, dass der Effekt feuert, sobald Map/Style bereit ist — + // ohne Nutzerinteraktion. Behebt das Problem, dass Waypoints erst nach + // dem ersten Tap auf den Waypoint-Button sichtbar wurden. + // • Ein kurzes delay(50) nach dem styleReady-Guard gibt dem MapLibre + // Annotation-Manager Zeit, sich vollständig zu initialisieren, bevor + // drawExplorerOverlaysMapLibre aufgerufen wird. + // v2.7.13 — routeRenderKey als zusätzlicher Key: + // • Stellt sicher, dass clipLineGeometry aktuell ist wenn activeTourRoute + // aus der DB geladen wird (relevant für Clip-Längenlinien). LaunchedEffect( - waypoints, + waypointRenderKey, primaryArgb, actionMenuWaypoint?.id, visibleTrack, @@ -897,10 +1374,19 @@ fun ExplorerScreenMapLibre( routePlanline, savedRouteLine, manualRoutePoints, - styleReady + styleReady, + mapLibreMap, + overlayRenderNonce, + routeRenderKey ) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect + // v2.7.30 — delay(150) statt yield(): Gibt MapLibre-AnnotationManager + // ausreichend Zeit zur vollständigen GL-Initialisierung nach onStyleLoaded. + // yield() (1 Frame) war zu kurz — addMarker() schlug still fehl und + // Waypoints/Puck wurden nicht gezeichnet. 150ms reichen zuverlässig. + kotlinx.coroutines.delay(150) + if (!styleReady) return@LaunchedEffect // Erneut prüfen nach dem delay drawExplorerOverlaysMapLibre( ml = ml, context = context, @@ -950,6 +1436,82 @@ fun ExplorerScreenMapLibre( ) } + // v2.7.31 — Dedizierter Marker-Retry nach 650ms: + // Root Cause: Nach setStyle() kann addMarker() im 150ms-Fenster still fehlschlagen, + // auch wenn addPolygon() bereits funktioniert (der AnnotationManager initialisiert + // Polygon-Layer vor Symbol-Layer). Resultat: Radius sichtbar, Marker fehlt. + // Dieser Effekt prüft 650ms nach Style-Ready, ob Waypoints vorhanden aber keine + // Marker gezeichnet wurden — und löst dann einen gezielten Rebuild aus. + // Idempotent: drawExplorerOverlaysMapLibre leert vor dem Neuzeichnen alle + // alten Annotations (inkl. ggf. vorhandener Polygone) — kein Double-Draw. + LaunchedEffect(styleReady, mapLibreMap, overlayRenderNonce) { + if (!styleReady) return@LaunchedEffect + if (mapLibreMap == null) return@LaunchedEffect + kotlinx.coroutines.delay(650) + if (!styleReady) return@LaunchedEffect + // Nur triggern wenn showMarkers=true, Waypoints vorhanden aber Marker-Cache leer: + // Das bedeutet addMarker() ist beim vorigen Versuch still fehlgeschlagen. + // waypoints is already filtered by selectedTour (see top of composable). + if (layerState.showMarkers && waypoints.isNotEmpty() && waypointMarkers.isEmpty()) { + overlayRenderNonce++ + } + } + + // v2.7.30 — Robuster Puck-Rehydrate nach Style-Load. + // Root Cause (v2.7.29): yield() war zu kurz; addMarker() schlug nach setStyle() + // still fehl, wenn der AnnotationManager noch nicht vollständig initialisiert war. + // Zusätzlich: early-return bei lastLat==null verhinderte den Puck-Draw selbst + // wenn später GPS-Fix eintraf (kein Retry-Mechanismus). + // Fix v2.7.30: + // 1. delay(200) statt yield() — gibt GL-Thread und AnnotationManager ausreichend Zeit. + // 2. runCatching um addMarker — verhindert silent-Crash im Coroutine-Scope. + // 3. Retry: Nach dem delay nochmals styleReady prüfen (kann sich geändert haben). + // 4. Kein early-return bei lastLat==null — LaunchedEffect arbeitet mit snapshot + // der letzten bekannten Koordinaten; wenn kein GPS-Fix: Puck wird spaäter + // vom LocationCallback gesetzt (bestehende Logik). Der Effekt zeichnet + // nur, wenn Koordinaten bekannt sind. + LaunchedEffect(overlayRenderNonce, styleReady, mapLibreMap) { + if (!styleReady) return@LaunchedEffect + val ml = mapLibreMap ?: return@LaunchedEffect + // delay(200): AnnotationManager braucht mehr Zeit als yield() nach Style-Load. + kotlinx.coroutines.delay(200) + if (!styleReady) return@LaunchedEffect // Erneut prüfen: Style könnte wechseln + val lat = lastLat ?: return@LaunchedEffect // Kein GPS-Fix: LocationCallbackübernimmt + val lng = lastLng ?: return@LaunchedEffect + val gp = LatLng(lat, lng) + // Alten Marker sicher entfernen (nach setStyle() bereits vom MapLibre geleert). + myLocationMarker?.let { runCatching { ml.removeMarker(it) } } + // v2.7.30 — runCatching: Verhindert silent-Crash wenn AnnotationManager + // noch nicht bereit ist. Marker wird null bei Fehler — LocationCallback + // setzt ihn beim nächsten GPS-Fix korrekt. + myLocationMarker = runCatching { + ml.addMarker( + MarkerOptions() + .position(gp) + .title("Mein Standort") + .icon( + puckBitmap?.let { bm -> IconFactory.getInstance(context).fromBitmap(bm) } + ?: IconFactory.getInstance(context).defaultMarker() + ) + ) + }.getOrNull() + // Genauigkeitskreis ebenfalls neu zeichnen. + accuracyPolygon?.let { runCatching { ml.removePolygon(it) } } + accuracyPolygon = null + val radius = lastAccuracyM?.toDouble() ?: 0.0 + if (radius > 0.0) { + accuracyPolygon = runCatching { + ml.addPolygon( + PolygonOptions() + .addAll(circleLatLng(lat, lng, radius, 60)) + .fillColor((primaryArgb and 0x00FFFFFF) or 0x33000000) + .strokeColor(primaryArgb) + .alpha(0.4f) + ) + }.getOrNull() + } + } + // Standort-Updates: My-Marker, Genauigkeitskreis, ggf. Follow. DisposableEffect(hasPermission) { if (!hasPermission) return@DisposableEffect onDispose { } @@ -1255,6 +1817,25 @@ fun ExplorerScreenMapLibre( Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_route_point_delete)) } + // v2.7.0 — Verlaufspunkt als Audio-Wegpunkt erstellen. + Button( + onClick = { + editDialogWaypoint = null + editDialogPrefill = Pair(pt.latitude, pt.longitude) + showEditDialog = true + showRoutePointDialog = false + selectedRoutePointIndex = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.AddLocation, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.tourverlauf_editor_as_waypoint)) + } } }, confirmButton = { @@ -1307,7 +1888,17 @@ fun ExplorerScreenMapLibre( prefillTourName = selectedTour, tourDefaults = tourDefaults, onConfirm = { newWaypoint -> - viewModel.upsert(newWaypoint) + // v2.7.7 — Spontane-Tour-Autocreate: + // Wenn ein NEUER Wegpunkt (editDialogWaypoint == null) im neutralen + // Explorer-Kontext gesetzt wird, erzeugt upsertFromNeutralExplorer + // automatisch eine echte "Spontane Tour" und speichert den Wegpunkt darin. + // Beim Bearbeiten eines bestehenden Wegpunkts (editDialogWaypoint != null) + // wird der normale upsert-Pfad verwendet. + if (editDialogWaypoint == null) { + viewModel.upsertFromNeutralExplorer(newWaypoint) + } else { + viewModel.upsert(newWaypoint) + } showEditDialog = false editDialogWaypoint = null editDialogPrefill = null @@ -1324,6 +1915,52 @@ fun ExplorerScreenMapLibre( ) } + // v2.7.0 — Tourverlauf-Editor-Sheet. + if (showTourverlaufEditor) { + TourverlaufEditorSheet( + routePoints = manualRoutePoints, + isDrawing = tapMode is TapModeMl.DrawRoutePoint, + tourName = selectedTour, + lastSelectedPointIndex = selectedRoutePointIndex, + onAddCenterPoint = { + val ml = mapLibreMap + val center = ml?.cameraPosition?.target + if (center != null) { + manualRoutePoints = manualRoutePoints + LatLng(center.latitude, center.longitude) + } else if (lastLat != null && lastLng != null) { + manualRoutePoints = manualRoutePoints + LatLng(lastLat!!, lastLng!!) + } + }, + onToggleDrawMode = { + manualRouteMode = !manualRouteMode + tapMode = if (manualRouteMode) TapModeMl.DrawRoutePoint else TapModeMl.None + }, + onRemoveLast = { + if (manualRoutePoints.isNotEmpty()) { + manualRoutePoints = manualRoutePoints.dropLast(1) + } + }, + onClear = { + manualRoutePoints = emptyList() + manualRouteMode = false + tapMode = TapModeMl.None + }, + onCreateWaypointFromPoint = { point -> + editDialogWaypoint = null + editDialogPrefill = Pair(point.latitude, point.longitude) + showEditDialog = true + showTourverlaufEditor = false + }, + onOpenOnlineRouting = { + showTourverlaufEditor = false + showRoutePlanner = true + }, + onDismiss = { + showTourverlaufEditor = false + } + ) + } + if (showRoutePlanner) { val locationLatLon: Pair? = lastLat?.let { lat -> lastLng?.let { lon -> lat to lon } @@ -1334,7 +1971,11 @@ fun ExplorerScreenMapLibre( RoutePlannerSheet( plan = routePlan, routeStatus = routeStatus, - hasRoute = routeLine.size >= 2, + // v2.7.22 — hasRoute schließt gespeicherte Routen (savedRouteLine) ein, + // damit das Sheet nicht "Noch keine Route" zeigt, wenn eine MANUAL- oder + // OSRM-TourRoute gespeichert ist, aber routeLine noch leer ist (kein + // aktiver OSRM-Lauf in dieser Session). + hasRoute = routeLine.size >= 2 || savedRouteLine.size >= 2, hasPlanline = routePlanline.size >= 2, isLoading = routeLoading, routingEndpoint = mapStyleSettings.routingEndpoint, @@ -1346,6 +1987,8 @@ fun ExplorerScreenMapLibre( // bliebe die alte Linie sichtbar, obwohl die Stops sich geändert haben. routeLine = emptyList() if (routeStatus != null) routeStatus = null + // v2.7.2 — Plan im ViewModel speichern, damit er Workspace-Wechsel überlebt. + viewModel.setCurrentSessionPlan(selectedTour, newPlan) }, onComputeRoute = { // v2.4.4: Wenn kein Endpunkt gesetzt ist, geben wir keinen @@ -1388,6 +2031,27 @@ fun ExplorerScreenMapLibre( de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) ) + // v2.6.2 — Berechnete Route im ViewModel sichern, damit sie + // nach einem Workspace-Wechsel wiederhergestellt werden kann. + // buildTourRouteFromState() benötigt routePlanline — wird + // inline berechnet (routeLine ist bereits gesetzt). + val sessionPlanline = routePlan.orderedStops() + .map { LatLng(it.latitude, it.longitude) } + .takeIf { it.size >= 2 } ?: emptyList() + val sessionRoute = buildTourRouteFromState( + plan = routePlan, + routeLine = routeLine, + routePlanline = sessionPlanline, + distanceMeters = result.distanceMeters, + durationSeconds = result.durationSeconds, + endpoint = endpoint, + planlineSourceLabel = routingPoiCtx.getString( + R.string.tour_route_source_planline + ) + ) + if (sessionRoute != null) { + viewModel.setCurrentSessionRoute(selectedTour, sessionRoute) + } }.onFailure { ex -> routeLine = emptyList() lastComputedDistanceMeters = null @@ -1421,19 +2085,57 @@ fun ExplorerScreenMapLibre( }, onShowPlanline = { routeLine = emptyList() - if (routePlanline.size >= 2) { + // v2.7.23 — Planlinie aus routePlanline (remember(routePlan)) lesen. + // Falls routePlanline noch leer ist aber Plan komplett: inline berechnen als Fallback. + val activePlanline = if (routePlanline.size >= 2) routePlanline + else routePlan.orderedStops() + .map { LatLng(it.latitude, it.longitude) } + .takeIf { it.size >= 2 } ?: emptyList() + if (activePlanline.size >= 2) { routeStatus = routingPoiCtx.getString(R.string.route_planner_planline_active) val ml = mapLibreMap if (ml != null) { - val mid = routePlanline[routePlanline.size / 2] + val mid = activePlanline[activePlanline.size / 2] ml.animateCamera(CameraUpdateFactory.newLatLngZoom(mid, 13.0)) } + // v2.7.2 — Planlinie als Session-Route persistieren, damit sie + // nach Workspace-Wechsel wieder sichtbar ist (savedRouteLine-Fallback). + // v2.7.3 — Zusätzlich dauerhaft in die DB schreiben (activeTourRoute), + // damit die Planlinie auch nach App-Neustart automatisch sichtbar ist. + // savedRouteLine liest activeTourRoute.polyline und zeigt sie sofort + // beim nächsten App-Start, noch bevor der async Plan-Restore läuft. + val planlineRoute = buildTourRouteFromState( + plan = routePlan, + routeLine = emptyList(), + routePlanline = activePlanline, + distanceMeters = null, + durationSeconds = null, + endpoint = routingPoiCtx.getString(R.string.tour_route_source_planline), + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (planlineRoute != null) { + viewModel.setCurrentSessionRoute(selectedTour, planlineRoute) + // v2.7.3 — DB-Persistenz: sicherstellen, dass die Planlinie + // auch nach einem App-Neustart (Prozess-Kill) sichtbar bleibt. + viewModel.saveTourRouteFor(selectedTour, planlineRoute) + } } }, onClear = { routePlan = RoutePlan() routeLine = emptyList() routeStatus = null + // v2.6.2 — Session-Route ebenfalls leeren: wenn der Nutzer + // explizit „Planer leeren“ wählt, soll keine Route wiederhergestellt werden. + viewModel.clearCurrentSessionRoute() + // v2.7.2 — Session-Plan leeren. + viewModel.clearCurrentSessionPlan() + // v2.7.3 — DB-PLANLINE-Route löschen, wenn der Nutzer explizit + // leert, damit sie nach App-Neustart nicht wieder erscheint. + // OSRM/MANUAL-Routen bleiben unangetastet (bewusste Nutzeraktion). + if (activeTourRoute?.source == de.waypointaudio.data.RouteSourceKind.PLANLINE) { + viewModel.deleteTourRouteForCurrent() + } }, onConfigureEndpoint = { showRoutePlanner = false @@ -1499,10 +2201,15 @@ fun ExplorerScreenMapLibre( currentTourName = selectedTour, savedTourRoute = activeTourRoute, onSaveTourRoute = { + // v2.7.23 — Inline Planline als Fallback wenn routePlanline noch nicht recomposed + val effectivePlanline = if (routePlanline.size >= 2) routePlanline + else routePlan.orderedStops() + .map { LatLng(it.latitude, it.longitude) } + .takeIf { it.size >= 2 } ?: emptyList() val route = buildTourRouteFromState( plan = routePlan, routeLine = routeLine, - routePlanline = routePlanline, + routePlanline = effectivePlanline, distanceMeters = lastComputedDistanceMeters, durationSeconds = lastComputedDurationSeconds, endpoint = lastComputedEndpoint, @@ -1510,14 +2217,20 @@ fun ExplorerScreenMapLibre( ) if (route != null) { viewModel.saveTourRouteForCurrent(route) + viewModel.setCurrentSessionRoute(selectedTour, route) routeStatus = routingPoiCtx.getString(R.string.tour_route_saved_msg, selectedTour) } }, onReplaceTourRoute = { + // v2.7.23 — Inline Planline als Fallback wenn routePlanline noch nicht recomposed + val effectivePlanline = if (routePlanline.size >= 2) routePlanline + else routePlan.orderedStops() + .map { LatLng(it.latitude, it.longitude) } + .takeIf { it.size >= 2 } ?: emptyList() val route = buildTourRouteFromState( plan = routePlan, routeLine = routeLine, - routePlanline = routePlanline, + routePlanline = effectivePlanline, distanceMeters = lastComputedDistanceMeters, durationSeconds = lastComputedDurationSeconds, endpoint = lastComputedEndpoint, @@ -1525,17 +2238,97 @@ fun ExplorerScreenMapLibre( ) if (route != null) { viewModel.saveTourRouteForCurrent(route) + viewModel.setCurrentSessionRoute(selectedTour, route) routeStatus = routingPoiCtx.getString(R.string.tour_route_replaced_msg, selectedTour) } }, onDeleteTourRoute = { viewModel.deleteTourRouteForCurrent() + // v2.6.2 — Auch die Session-Route löschen, damit kein + // Wiederherstellungsversuch nach dem nächsten Workspace-Wechsel erfolgt. + viewModel.clearCurrentSessionRoute() + // v2.7.2 — Session-Plan ebenfalls leeren. + viewModel.clearCurrentSessionPlan() + routePlan = RoutePlan() + routeLine = emptyList() routeStatus = routingPoiCtx.getString(R.string.tour_route_deleted_msg, selectedTour) }, currentLocationLatLon = locationLatLon, mapCenterLatLon = mapCenterLatLon, availableWaypoints = waypoints, - onDismiss = { showRoutePlanner = false } + onDismiss = { showRoutePlanner = false }, + // v2.7.19 — Manuelles Zeichnen aus dem Tourplaner öffnen. + // Schließt den Tourplaner und öffnet den TourverlaufEditorSheet. + // Die Funktion bleibt vollständig erhalten; der Einstieg ist jetzt + // logisch im Tourplaner verankert statt als separater FAB. + onOpenDrawMode = { + showRoutePlanner = false + showTourverlaufEditor = true + }, + // v2.7.20 — Zwischenpunkt direkt per Karten-Tap setzen. + // Schließt den Sheet und aktiviert AddViaPoint-TapMode. + // Wenn kein Start/Ziel vorhanden: Modus trotzdem aktivieren — + // der erste Tap wird dann als Start gewertet (via intuitivem Fallback). + onStartAddViaOnMap = { + showRoutePlanner = false + tapMode = TapModeMl.AddViaPoint + }, + // v2.7.23 — Route Planner Flow Fix: Planlinie/Route direkt als Tourverlauf übernehmen. + // Wird aufgerufen wenn Nutzer den neuen "Als Tourverlauf übernehmen" Button drückt. + // Funktioniert unabhängig davon, ob bereits eine gespeicherte Route existiert: + // - Wenn routeLine >= 2 (OSRM erfolgt): berechnete Route als OSRM-TourRoute speichern. + // - Sonst wenn routePlanline >= 2 (plan komplett): Planlinie als PLANLINE-TourRoute speichern. + // - Setzt routeStatus mit klar lesbarem Feedback. + // Waypoint-Preservation-Invariante bleibt gewahrt: nur KEY_TOUR_ROUTES wird geschrieben. + onAdoptAsTourRoute = { + val ml = mapLibreMap + val planline = routePlanline + val line = routeLine + when { + line.size >= 2 -> { + // OSRM-Route verfügbar: als OSRM-TourRoute speichern + val route = buildTourRouteFromState( + plan = routePlan, + routeLine = line, + routePlanline = planline, + distanceMeters = lastComputedDistanceMeters, + durationSeconds = lastComputedDurationSeconds, + endpoint = lastComputedEndpoint, + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (route != null) { + viewModel.saveTourRouteFor(selectedTour, route) + viewModel.setCurrentSessionRoute(selectedTour, route) + routeStatus = routingPoiCtx.getString(R.string.route_planner_adopted_route) + } + } + planline.size >= 2 -> { + // Planlinie (Luftlinie) als PLANLINE-TourRoute speichern + val route = buildTourRouteFromState( + plan = routePlan, + routeLine = emptyList(), + routePlanline = planline, + distanceMeters = null, + durationSeconds = null, + endpoint = routingPoiCtx.getString(R.string.tour_route_source_planline), + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (route != null) { + viewModel.saveTourRouteFor(selectedTour, route) + viewModel.setCurrentSessionRoute(selectedTour, route) + routeStatus = routingPoiCtx.getString(R.string.route_planner_adopted_planline) + // Kamera auf Planlinienmitte zentrieren + if (ml != null) { + val mid = planline[planline.size / 2] + ml.animateCamera(CameraUpdateFactory.newLatLngZoom(mid, 13.0)) + } + } + } + else -> { + routeStatus = routingPoiCtx.getString(R.string.route_planner_adopt_none) + } + } + } ) } @@ -1558,7 +2351,8 @@ fun ExplorerScreenMapLibre( tourName = selectedTour, isActive = true ) - viewModel.upsert(newWp) + // v2.7.7 — POI als Wegpunkt im neutralen Kontext → Spontane Tour + viewModel.upsertFromNeutralExplorer(newWp) }, onAddPoiToStoryboard = { poi -> val newWp = Waypoint( @@ -1571,7 +2365,8 @@ fun ExplorerScreenMapLibre( tourName = selectedTour, isActive = true ) - viewModel.upsert(newWp) + // v2.7.7 — POI zu Storyboard im neutralen Kontext → Spontane Tour + viewModel.upsertFromNeutralExplorer(newWp) showStoryboardSheet = true }, onComputeRoute = { @@ -1597,6 +2392,17 @@ fun ExplorerScreenMapLibre( de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) ) + // v2.6.2 — Auch Legacy-POI-Route im ViewModel sichern. + val legacyRoute = de.waypointaudio.data.TourRoute( + polyline = result.polyline.map { + de.waypointaudio.data.TourRoutePoint(it.first, it.second) + }, + source = de.waypointaudio.data.RouteSourceKind.OSRM, + endpoint = endpoint, + distanceMeters = result.distanceMeters, + durationSeconds = result.durationSeconds + ) + viewModel.setCurrentSessionRoute(selectedTour, legacyRoute) }.onFailure { routeLine = emptyList() routeStatus = routingPoiCtx.getString(R.string.routing_compute_error) @@ -1813,7 +2619,7 @@ fun ExplorerScreenMapLibre( MapModeSegmentedHeader( current = currentMode, onChange = onModeChange, - subtitle = selectedTour, + subtitle = displayTourLabel, contentColor = MaterialTheme.colorScheme.onSurface ) } else { @@ -1829,7 +2635,7 @@ fun ExplorerScreenMapLibre( Text(stringResource(R.string.explorer_title)) } Text( - text = selectedTour, + text = displayTourLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) ) @@ -1932,33 +2738,44 @@ fun ExplorerScreenMapLibre( ) { Icon(Icons.Filled.MovieFilter, contentDescription = stringResource(R.string.explorer_storyboard_open)) } - SmallFloatingActionButton( - onClick = { showRoutePlanner = true }, - containerColor = neutralFabContainer, - contentColor = neutralFabContent - ) { - Icon(Icons.Filled.AltRoute, contentDescription = stringResource(R.string.route_planner_open)) - } - // v2.5.3 — FAB für „Route zeichnen". Toggle: an = Tap setzt - // stille Routenpunkte, aus = normale Karten-Tap-Logik. + // v2.7.16 — Route planen FAB (wiederhergestellt): direkter Einstieg in RoutePlannerSheet. + // Start/Ziel/Via, Planlinie, Online-Routing. War in v2.7.15 fälschlich entfernt worden. SmallFloatingActionButton( onClick = { - manualRouteMode = !manualRouteMode - tapMode = if (manualRouteMode) TapModeMl.DrawRoutePoint - else TapModeMl.None + showRoutePlanner = !showRoutePlanner }, - containerColor = if (manualRouteMode) + containerColor = if (showRoutePlanner) + MaterialTheme.colorScheme.secondaryContainer + else + neutralFabContainer, + contentColor = if (showRoutePlanner) + MaterialTheme.colorScheme.onSecondaryContainer + else + neutralFabContent + ) { + Icon( + Icons.Filled.AltRoute, + contentDescription = stringResource(R.string.route_planner_fab_hint) + ) + } + // v2.7.0 — Tourverlauf-Editor-FAB: manuelles Setzen/Zeichnen von Verlaufspunkten. + // Öffnet das TourverlaufEditorSheet; aktiver Tipp-Modus färbt den FAB ein. + SmallFloatingActionButton( + onClick = { + showTourverlaufEditor = !showTourverlaufEditor + }, + containerColor = if (showTourverlaufEditor || manualRouteMode) MaterialTheme.colorScheme.tertiaryContainer else neutralFabContainer, - contentColor = if (manualRouteMode) + contentColor = if (showTourverlaufEditor || manualRouteMode) MaterialTheme.colorScheme.onTertiaryContainer else neutralFabContent ) { Icon( Icons.Filled.Edit, - contentDescription = stringResource(R.string.explorer_mode_draw_route) + contentDescription = stringResource(R.string.tourverlauf_editor_fab_hint) ) } if (isRecording) { @@ -2061,18 +2878,36 @@ fun ExplorerScreenMapLibre( MapBaseStyle.CLASSIC, MapBaseStyle.SATELLITE -> Style.Builder().fromJson(buildClassicOsmStyleJson()) else -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron") } + val initialTargetStyle = layerState.baseStyle + val initialTargetTilt = if (initialTargetStyle == MapBaseStyle.CITY_3D || + initialTargetStyle == MapBaseStyle.TERRAIN_3D || + initialTargetStyle == MapBaseStyle.PERSPECTIVE) + mapStyleSettings.perspectiveTilt else 0 ml.setStyle(initialStyleBuilder) { loadedStyle -> // 3D-Extrusion für CITY_3D beim initialen Laden. - if (layerState.baseStyle == MapBaseStyle.CITY_3D || - layerState.baseStyle == MapBaseStyle.TERRAIN_3D || - layerState.baseStyle == MapBaseStyle.PERSPECTIVE) { + if (initialTargetStyle == MapBaseStyle.CITY_3D || + initialTargetStyle == MapBaseStyle.TERRAIN_3D || + initialTargetStyle == MapBaseStyle.PERSPECTIVE) { try { addBuildingExtrusionIfAvailable(loadedStyle) } catch (_: Exception) {} val pos2 = ml.cameraPosition ml.cameraPosition = CameraPosition.Builder(pos2) .tilt(mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0)) .build() } + // v2.7.28 — appliedBaseStyle + appliedPerspectiveTilt nach + // initialem Style-Load setzen. Markiert, welcher Stil + // tatsächlich geladen wurde, damit der Style-Change-LaunchedEffect + // einen etwaigen gespeicherten Stil (z.B. OUTDOOR) erneut + // anwenden kann, falls dieser vor styleReady=true in + // layerState gesetzt wurde. + appliedBaseStyle = initialTargetStyle + appliedPerspectiveTilt = initialTargetTilt styleReady = true + // v2.7.12 — Initialen Overlay-Draw ohne Nutzerinteraktion + // auslösen: Nonce erhöhen, damit Overlay-LaunchedEffect + // garantiert neu feuert, auch wenn waypoints/styleReady + // sich nicht verändert haben. + overlayRenderNonce++ } // Klick-Handler für Wegpunkt-/Track-Marker. ml.setOnMarkerClickListener { m -> @@ -2167,6 +3002,36 @@ fun ExplorerScreenMapLibre( selectedRoutePointIndex = null true } + is TapModeMl.AddViaPoint -> { + // v2.7.20 — Zwischenpunkt/Route-formen per Karten-Tap. + // Kein Audio-Waypoint, kein manualRoutePoint. + // Intuitiver Fallback: + // - Kein Start: erster Tap = Start + // - Kein Ziel: zweiter Tap = Ziel + // - Start+Ziel vorhanden: Tap = Via/Zwischenpunkt + val coordLabel = "%.4f,%.4f".format(java.util.Locale.US, p.latitude, p.longitude) + val newStop = RouteStop( + latitude = p.latitude, + longitude = p.longitude, + label = coordLabel, + source = RouteStopSource.MAP_CENTER + ) + val newPlan = when { + routePlan.start == null -> + routePlan.copy(start = newStop.copy(label = "Start $coordLabel")) + routePlan.destination == null -> + routePlan.copy(destination = newStop.copy(label = "Ziel $coordLabel")) + else -> + routePlan.copy(via = routePlan.via + newStop.copy(label = "Zwischenpunkt $coordLabel")) + } + routePlan = newPlan + routeLine = emptyList() + if (routeStatus != null) routeStatus = null + viewModel.setCurrentSessionPlan(selectedTour, newPlan) + // Modus aktiv lassen (weitere Zwischenpunkte/Start/Ziel setzbar) + // Nutzer kann per Cancel-Chip abbrechen. + true + } TapModeMl.None -> false } } @@ -2185,21 +3050,16 @@ fun ExplorerScreenMapLibre( showEditDialog = true true } - // Folow-disable bei manueller Bewegung: + // v2.7.26 — Follow-disable bei manueller Bewegung: + // Jede bewusste Wischgeste schaltet Standort folgen sofort aus, + // unabhängig davon wie weit die Kamera vom Live-Standort entfernt ist. + // Der Nutzer soll die Karte stabil halten können, ohne dass Follow + // bei der nächsten GPS-Meldung wieder zentriert. ml.addOnMoveListener(object : org.maplibre.android.maps.MapLibreMap.OnMoveListener { override fun onMoveBegin(detector: org.maplibre.android.gestures.MoveGestureDetector) { + // Jede Wischgeste deaktiviert Follow sofort. if (followLocation) { - val lat = lastLat; val lng = lastLng - if (lat != null && lng != null) { - val tgt = ml.cameraPosition.target - if (tgt != null) { - val dLat = kotlin.math.abs(tgt.latitude - lat) - val dLng = kotlin.math.abs(tgt.longitude - lng) - if (dLat > 0.00040 || dLng > 0.00040) { - followLocation = false - } - } - } + followLocation = false } } override fun onMove(detector: org.maplibre.android.gestures.MoveGestureDetector) {} @@ -2296,6 +3156,8 @@ fun ExplorerScreenMapLibre( stringResource(R.string.explorer_mode_draw_route_active) is TapModeMl.MoveRoutePoint -> stringResource(R.string.explorer_route_point_move_active) + is TapModeMl.AddViaPoint -> + stringResource(R.string.explorer_tap_add_via_active) TapModeMl.None -> null } if (tapHint != null) { @@ -2314,6 +3176,7 @@ fun ExplorerScreenMapLibre( val statusIcon = when { isRecording -> Icons.Filled.FiberManualRecord activeDraft != null -> Icons.Filled.Timeline + manualRouteMode || manualRoutePoints.isNotEmpty() -> Icons.Filled.Edit else -> Icons.Filled.Place } val statusText = when { @@ -2324,12 +3187,17 @@ fun ExplorerScreenMapLibre( R.string.explorer_status_draft_compact, activeDraft.markedCount, activeDraft.pointCount ) + // v2.7.0 — Verlaufspunkte im Status anzeigen, wenn Editor aktiv/Punkte gesetzt. + manualRouteMode || manualRoutePoints.isNotEmpty() -> stringResource( + R.string.tourverlauf_editor_point_count, manualRoutePoints.size + ) else -> stringResource( R.string.explorer_status_compact, waypoints.size ) } val statusTint = when { isRecording -> MaterialTheme.colorScheme.error + manualRouteMode || manualRoutePoints.isNotEmpty() -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.primary } Icon( @@ -2406,6 +3274,8 @@ fun ExplorerScreenMapLibre( }, onShowRoutingInfo = { showRoutingInfo = true }, onShowRoutePlanner = { showRoutePlanner = true }, + // v2.7.15 — Tourverlauf-Editor-Einstieg vom Layer-Panel aus + onShowTourverlaufEditor = { showTourverlaufEditor = true }, // v2.5.45 — Auto-Load-Toggle poiAutoLoadEnabled = autoPoiLoadEnabled, onPoiAutoLoadChange = { enabled -> @@ -2687,7 +3557,8 @@ fun ExplorerScreenMapLibre( name = poi.displayName, tourName = selectedTour, ) - viewModel.upsert(newWp) + // v2.7.7 — Explorer-POI im neutralen Kontext → Spontane Tour + viewModel.upsertFromNeutralExplorer(newWp) selectedExplorerPoi = null explorerPoiSnackbar = "\"${poi.displayName}\" als Wegpunkt angelegt." }) { @@ -2725,12 +3596,18 @@ private sealed interface TapModeMl { data object DrawRoutePoint : TapModeMl /** v2.5.3 — Verschieben eines Routenpunkts an Index [index] der Polyline. */ data class MoveRoutePoint(val index: Int) : TapModeMl + /** v2.7.20 — Zwischenpunkt-Modus: Tippen fügt Via-Stop zur RoutePlan hinzu. */ + data object AddViaPoint : TapModeMl } // ─────────────────────────────────────────────────────────────────────────── // Overlay-Renderer (MapLibre Annotation API) // ─────────────────────────────────────────────────────────────────────────── +// v2.7.30 — clearAllAnnotationsCaches: planlinePolylines + routeStopMarkers ergänzt. +// In v2.7.29 fehlten diese beiden Listen: Nach setStyle() blieben veraltete +// planlinePolylines-Referenzen im Cache, die beim nächsten Render zu +// removePolyline()-Aufrufen auf ungültigen Objekten führten (stiløer Fehler). private fun clearAllAnnotationsCaches( waypointMarkers: MutableList, waypointMarkerIdToWaypoint: MutableMap, @@ -2743,6 +3620,9 @@ private fun clearAllAnnotationsCaches( manualRoutePolylines: MutableList? = null, manualRouteMarkers: MutableList? = null, manualRouteMarkerIdToIndex: MutableMap? = null, + // v2.7.30 — Neu: planlinePolylines + routeStopMarkers ergänzt + planlinePolylines: MutableList? = null, + routeStopMarkers: MutableList? = null, ) { waypointMarkers.clear() waypointMarkerIdToWaypoint.clear() @@ -2755,6 +3635,8 @@ private fun clearAllAnnotationsCaches( manualRoutePolylines?.clear() manualRouteMarkers?.clear() manualRouteMarkerIdToIndex?.clear() + planlinePolylines?.clear() // v2.7.30 + routeStopMarkers?.clear() // v2.7.30 } @Suppress("LongParameterList") @@ -2804,24 +3686,13 @@ private fun drawExplorerOverlaysMapLibre( val iconFactory = IconFactory.getInstance(context) // Track-Polyline (Halo + Linie). + // v2.7.30 — runCatching: Verhindert silent-Crash wenn AnnotationManager noch nicht bereit. if (track.size >= 2) { val pts = track.map { LatLng(it.latitude, it.longitude) } - val halo = ml.addPolyline( - PolylineOptions() - .addAll(pts) - .color(0xFFFFFFFF.toInt()) - .width(10f) - .alpha(0.85f) - ) - val line = ml.addPolyline( - PolylineOptions() - .addAll(pts) - .color(trackArgb) - .width(6f) - .alpha(1.0f) - ) - trackPolylines += halo - trackPolylines += line + runCatching { ml.addPolyline(PolylineOptions().addAll(pts).color(0xFFFFFFFF.toInt()).width(10f).alpha(0.85f)) } + .getOrNull()?.let { trackPolylines += it } + runCatching { ml.addPolyline(PolylineOptions().addAll(pts).color(trackArgb).width(6f).alpha(1.0f)) } + .getOrNull()?.let { trackPolylines += it } } if (track.isNotEmpty()) { @@ -2841,11 +3712,10 @@ private fun drawExplorerOverlaysMapLibre( else -> trackArgb } val bm = trackPointBitmap(sizePx, color, isSelected) - val marker = ml.addMarker( - MarkerOptions() - .position(gp) - .icon(iconFactory.fromBitmap(bm)) - ) + // v2.7.30 — runCatching: Track-Marker robust gegen AnnotationManager-Nicht-Bereitschaft. + val marker = runCatching { + ml.addMarker(MarkerOptions().position(gp).icon(iconFactory.fromBitmap(bm))) + }.getOrNull() ?: return@forEachIndexed trackMarkers += marker trackMarkerIdToIndex[marker.id] = idx } @@ -2898,38 +3768,41 @@ private fun drawExplorerOverlaysMapLibre( de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 5f else -> 4f } - val halo = ml.addPolyline( - PolylineOptions() - .addAll(segPoints) - .color(0xFFFFFFFF.toInt()) - .width(w + 3f) - .alpha(0.85f) - ) - val line = ml.addPolyline( - PolylineOptions() - .addAll(segPoints) - .color(color) - .width(w) - .alpha(0.95f) - ) - audioLinePolylines += halo - audioLinePolylines += line + // v2.7.30 — runCatching um addPolyline: Audio-Linien robust. + runCatching { ml.addPolyline(PolylineOptions().addAll(segPoints).color(0xFFFFFFFF.toInt()).width(w + 3f).alpha(0.85f)) } + .getOrNull()?.let { audioLinePolylines += it } + runCatching { ml.addPolyline(PolylineOptions().addAll(segPoints).color(color).width(w).alpha(0.95f)) } + .getOrNull()?.let { audioLinePolylines += it } } } + // v2.7.31 — Waypoint Marker Visibility Fix: Render in 2 separate passes. + // Pass 1: Radius polygons only. Polygons must be added BEFORE markers so that + // MapLibre's legacy annotation manager places them on a lower Z layer. + // In v2.7.30 the mixed loop worked for the polygon but addMarker() could fail + // silently (AnnotationManager not fully ready) leaving only the radius visible. + if (showRadius) { + for (wp in waypoints) { + if (wp.radiusMeters > 0) { + runCatching { + ml.addPolygon( + PolygonOptions() + .addAll(circleLatLng(wp.latitude, wp.longitude, wp.radiusMeters.toDouble(), 48)) + .fillColor((primaryArgb and 0x00FFFFFF) or 0x40000000) + .strokeColor(primaryArgb) + .alpha(0.5f) + ) + }.getOrNull()?.let { waypointRadiusPolygons += it } + } + } + } + + // Pass 2: Waypoint markers — drawn AFTER all polygons so they render on top. + // v2.7.31: runCatching preserved; if addMarker() fails (AnnotationManager not + // ready), the marker is skipped here and the 650ms dedicated marker-retry + // LaunchedEffect will trigger a re-draw via overlayRenderNonce++. for (wp in waypoints) { val gp = LatLng(wp.latitude, wp.longitude) - if (showRadius && wp.radiusMeters > 0) { - val circle = ml.addPolygon( - PolygonOptions() - .addAll(circleLatLng(wp.latitude, wp.longitude, wp.radiusMeters.toDouble(), 48)) - .fillColor((primaryArgb and 0x00FFFFFF) or 0x40000000) - .strokeColor(primaryArgb) - .alpha(0.5f) - ) - waypointRadiusPolygons += circle - } - val isSelected = wp.id == selectedId val durationMs = if (showAudioLength) { audioDurationsMs[wp.soundUri] ?: 0L @@ -2939,24 +3812,28 @@ private fun drawExplorerOverlaysMapLibre( } else { waypointDotPlain(primaryArgb, density, isSelected) } - val marker = ml.addMarker( - MarkerOptions() - .position(gp) - .title(wp.name.ifBlank { "Wegpunkt" }) - .icon(iconFactory.fromBitmap(bm)) - ) + val marker = runCatching { + ml.addMarker( + MarkerOptions() + .position(gp) + .title(wp.name.ifBlank { "Wegpunkt" }) + .icon(iconFactory.fromBitmap(bm)) + ) + }.getOrNull() ?: continue waypointMarkers += marker waypointMarkerIdToWaypoint[marker.id] = wp } if (searchPin != null) { val pinBm = makeSearchPinBitmapShared(96) - val pinMarker = ml.addMarker( - MarkerOptions() - .position(searchPin) - .icon(iconFactory.fromBitmap(pinBm)) - ) - searchPinMarkerRef(pinMarker) + // v2.7.30 — runCatching: Search-Pin robust. + runCatching { + ml.addMarker( + MarkerOptions() + .position(searchPin) + .icon(iconFactory.fromBitmap(pinBm)) + ) + }.getOrNull()?.let { searchPinMarkerRef(it) } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt index 1913300..dfc285e 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt @@ -74,7 +74,9 @@ fun HomeScreen( val store = remember(context) { HomeModulesSettingsStore(context.applicationContext) } val scope = rememberCoroutineScope() - val tourList by viewModel.tourList.collectAsState() + // v2.7.5 — visibleTourList blendet Standard-Tour (Blueprint) aus, sobald echte + // Touren vorhanden sind. Intern bleibt Standard-Tour als Fallback erhalten. + val tourList by viewModel.visibleTourList.collectAsState() val waypoints by viewModel.waypoints.collectAsState() val tourRoutes by viewModel.tourRoutes.collectAsState() val tourCovers by viewModel.tourCovers.collectAsState() @@ -240,16 +242,24 @@ fun HomeScreen( ) Spacer(Modifier.width(8.dp)) Text( - text = "Touren", + text = "Meine Touren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f) ) - Text( - text = "${tourList.size}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + .padding(horizontal = 10.dp, vertical = 3.dp) + ) { + Text( + text = "${tourList.size}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } } // ─ Cover-Grid ─ @@ -522,8 +532,14 @@ private fun TourCoverCard( } } - // Text-Block - Column(modifier = Modifier.padding(12.dp)) { + // Text-Block — feste Struktur (Titel, eine Metazeile, Chips-Zeile), + // damit alle Karten gleich hoch wirken und nichts abgeschnitten wird. + val isEmptyTour = waypointCount == 0 + Column( + modifier = Modifier + .padding(12.dp) + .heightIn(min = 86.dp) + ) { Text( text = tourName, style = MaterialTheme.typography.titleSmall, @@ -536,26 +552,34 @@ private fun TourCoverCard( Text( text = subtitleFor(waypointCount, hasRoute), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (isEmptyTour) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.weight(1f)) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically ) { - Badge( - icon = Icons.Filled.PinDrop, - text = "$waypointCount" - ) - if (hasRoute) { + if (isEmptyTour) { Badge( - icon = Icons.Filled.Route, - text = "Route", - tint = MaterialTheme.colorScheme.tertiary + icon = Icons.Filled.Add, + text = "Noch leer", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Badge( + icon = Icons.Filled.PinDrop, + text = "$waypointCount" ) } + Badge( + icon = Icons.Filled.Route, + text = if (hasRoute) "Route" else "Keine Route", + tint = if (hasRoute) MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -710,10 +734,13 @@ private fun PttModuleCard( // Wiederverwendung der bestehenden LivePttCard (volle Funktion: Mic-Permission, // Aufnahmen, Geräte). Die Card wird hier nur in einen Wrapper gehüllt, der // Abstände/Form an die übrige Bibliothek-Oberfläche anpasst. + // v2.7.35 — Live/PTT optisch zurückgenommen: an den Bibliothek-Rand + // eingerückt und vertikal kompakter, damit es die Touren nicht dominiert. + // KEINE Verhaltensänderung an der LivePttCard selbst. LivePttCard( pttManager = pttManager, onOpenRecordings = onOpenRecordings, - modifier = Modifier.padding(horizontal = 0.dp, vertical = 6.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) ) } @@ -869,11 +896,11 @@ private fun ToggleRow( /* ───────────────────────────── Helpers ─────────────────────────────────── */ -private fun subtitleFor(waypointCount: Int, hasRoute: Boolean): String { - val parts = mutableListOf() - parts.add("$waypointCount Wegpunkte") - if (hasRoute) parts.add("Route gespeichert") - return parts.joinToString(" · ") +private fun subtitleFor(waypointCount: Int, hasRoute: Boolean): String = when { + waypointCount == 0 -> "Noch leer · füge Wegpunkte hinzu" + waypointCount == 1 -> if (hasRoute) "1 Wegpunkt · Route geplant" else "1 Wegpunkt" + else -> if (hasRoute) "$waypointCount Wegpunkte · Route geplant" + else "$waypointCount Wegpunkte" } private fun atmoSourceLabel(type: MusicSourceType): String = when (type) { diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt index 2143754..2da7b81 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.GpsFixed @@ -106,6 +107,8 @@ fun MapLayerFab( onPerspectiveTiltChange: (Int) -> Unit = {}, onShowRoutingInfo: () -> Unit = {}, onShowRoutePlanner: () -> Unit = {}, + // v2.7.15 — Unified Tourverlauf-Editor-Einstieg (zusammengeführt aus RoutePlanner + Editor) + onShowTourverlaufEditor: () -> Unit = {}, // v2.5.45 — Auto-POI-Load-Toggle poiAutoLoadEnabled: Boolean = true, onPoiAutoLoadChange: (Boolean) -> Unit = {}, @@ -546,10 +549,11 @@ fun MapLayerFab( } } - // v2.4.2 — Routenplaner-Eintrag im Layer-Panel. + // v2.7.16 — Route planen (wiederhergestellt): direkter Einstieg in RoutePlannerSheet. + // Start/Ziel/Via, Planlinie, Online-Routing. War in v2.7.15 fälschlich entfernt. Surface( shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.65f), modifier = Modifier.fillMaxWidth() ) { Row( @@ -560,23 +564,63 @@ fun MapLayerFab( Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.route_planner_menu_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + stringResource(R.string.route_planner_menu_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.78f) + ) + } + TextButton(onClick = { + showSheet = false + onShowRoutePlanner() + }) { + Text(stringResource(R.string.route_planner_menu_open)) + } + } + } + // v2.7.15 / v2.7.16 — Tourverlauf bearbeiten: manuelles Zeichnen/Setzen von Verlaufspunkten. + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Edit, + contentDescription = null, + modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer ) Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( - stringResource(R.string.route_planner_open_short), + stringResource(R.string.tourverlauf_menu_title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer ) Text( - stringResource(R.string.route_planner_subtitle), + stringResource(R.string.tourverlauf_menu_subtitle), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f) ) } - TextButton(onClick = onShowRoutePlanner) { - Text(stringResource(R.string.route_planner_open)) + TextButton(onClick = { + showSheet = false + onShowTourverlaufEditor() + }) { + Text(stringResource(R.string.tourverlauf_menu_open)) } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt index f52fa80..6e306e9 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt @@ -130,7 +130,8 @@ fun MapScreen( ) { val allWaypoints by viewModel.waypoints.collectAsState() val selectedTour by viewModel.selectedTour.collectAsState() - val tourList by viewModel.tourList.collectAsState() + // v2.7.5 — visibleTourList: Standard-Tour in Kartenansicht-Kontext ausblenden + val tourList by viewModel.visibleTourList.collectAsState() val tourDefaults by viewModel.tourDefaults.collectAsState() val currentTrack by viewModel.currentTrack.collectAsState() val isRecording by viewModel.isRecording.collectAsState() diff --git a/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt b/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt index b0c72ee..21be38d 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt @@ -455,7 +455,8 @@ fun WaypointChooserDialog( onDismiss: () -> Unit ) { val context = LocalContext.current - val tourList by viewModel.tourList.collectAsState() + // v2.7.5 — visibleTourList: Standard-Tour wird aus PTT-Ziel-Dropdown ausgeblendet + val tourList by viewModel.visibleTourList.collectAsState() val selectedTour by viewModel.selectedTour.collectAsState() val allWaypoints by viewModel.waypoints.collectAsState() val currentLocation by viewModel.currentLocation.collectAsState() diff --git a/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt index 85e9c19..8e903e8 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt @@ -1,5 +1,8 @@ package de.waypointaudio.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,19 +34,26 @@ import androidx.compose.material.icons.filled.DirectionsWalk import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Gesture import androidx.compose.material.icons.filled.MovieFilter import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Route import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Timeline import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -66,6 +76,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import de.waypointaudio.R @@ -76,19 +87,34 @@ import de.waypointaudio.util.OverpassPoiClient import kotlinx.coroutines.launch /** - * v2.4.2 — Explorer-Routenplaner als ModalBottomSheet. + * v2.4.2 — Explorer-Tourverlauf-Planer als ModalBottomSheet. + * v2.7.17 — Route-first Explorer: Umbenennung Verlaufspunkte -> Tourpunkte, + * UI-Sprache: „Route planen", „Tourpunkt", „Audio hinzufügen". Keine Logik-Änderungen. + * v2.6.0 — Umbenannt von Routenplaner auf Tourverlauf. Begriffe angepasst. + * v2.6.1 — Layout-Fix: Endpoint-Karte neu strukturiert. Langer URL-Text + * bricht nicht mehr vertikal in einzelne Buchstaben um. „Konfigurieren"- + * Button in eigene Zeile unterhalb der URL verschoben; URL zeigt jetzt + * maxLines=1 mit horizontalem Ellipsis. Keine Logik-Änderungen. + * v2.7.24 — Tourverlauf UX Polish: + * - Freie Linie als PRIMÄRES Werkzeug oben, visuell hervorgehoben (kein "Fallback"-Label). + * - Route über Punkte formen als zweite klare Option. + * - Experimentelles Online-Routing in einklappbarem Bereich nachrangig platziert. + * - Sticky Action Bar vereinfacht: "Als Tourverlauf übernehmen" primär. + * - Keine Logik-Änderungen, kein Regressionsrisiko. * * Bewusste Gestaltungsentscheidungen: * - Eigenständige Komponente, keine Vermischung mit dem alten * [RoutingPoiSheet] (v2.3.1 Info-Sheet bleibt für Backward-Compat erhalten). * - State (Plan, Status, Route-Polyline) wird vom Aufrufer * ([ExplorerScreenMapLibre]) gehalten — der Sheet ist reine UI. - * - Stops können aus mehreren Quellen kommen (Standort, Kartenmitte, + * - Verlaufspunkte können aus mehreren Quellen kommen (Standort, Kartenmitte, * Wegpunkt, POI, manuell, Suche) — siehe [RouteStopSource]. - * - „Route berechnen“ wird **nur auf Nutzerklick** ausgelöst (sparsam, - * kein Polling, keine Auto-Berechnung). - * - Wenn kein Endpunkt konfiguriert ist: klare Meldung + Planlinien- - * Fallback (Luftlinie zwischen den Stops, ohne Netzwerk). + * - „Route berechnen" ist optional/sekundär; wird **nur auf Nutzerklick** + * ausgelöst. Die Planlinie genügt für den Tourverlauf. + * - Online-Routing (OSRM) bleibt funktional, wird aber als optional + * und experimentell eingestuft — kein Ausbau geplant. + * - Wenn kein Endpunkt konfiguriert: klare Meldung + Planlinie + * (Luftlinie zwischen den Verlaufspunkten, ohne Netzwerk). */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -121,6 +147,22 @@ fun RoutePlannerSheet( mapCenterLatLon: Pair?, availableWaypoints: List, onDismiss: () -> Unit, + // v2.7.19 — Manuelles Zeichnen als integriertes Bearbeitungs-Werkzeug des Tourplaners. + // Öffnet den TourverlaufEditorSheet direkt aus dem Tourplaner heraus, ohne + // separaten FAB/Einstieg. Der Tourplaner ist der zentrale Ort für Route planen/bearbeiten. + onOpenDrawMode: () -> Unit = {}, + // v2.7.20 — Zwischenpunkt per Karten-Tap setzen. + // Schließt den Sheet und aktiviert den AddViaPoint-TapMode auf der Karte. + // Ist null/kein Kontext verfügbar, wenn Start und Ziel nicht gesetzt sind — der + // Callback wird im Sheet trotzdem immer angeboten, der Status-Hinweis übernimmt + // die Führung. + onStartAddViaOnMap: () -> Unit = {}, + // v2.7.23 — Planlinie/Route direkt als Tourverlauf übernehmen. + // Speichert die aktuell sichtbare Planlinie (oder berechnete Route) dauerhaft + // als Tourverlauf. Aktiviert wenn Start+Ziel gesetzt (hasPlanline oder hasRoute). + // Dies ist der primäre Weg, um eine Planlinie in den persistierten Tourverlauf + // zu überführen — unabhängig davon, ob bereits eine gespeicherte Route existiert. + onAdoptAsTourRoute: () -> Unit = {}, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val ctx = LocalContext.current @@ -133,6 +175,9 @@ fun RoutePlannerSheet( var showManualPicker by remember { mutableStateOf(null) } var showSaveTourDialog by remember { mutableStateOf(false) } + // v2.7.24 — Online-Routing-Bereich standardmäßig eingeklappt (experimentell/nachrangig) + var onlineRoutingExpanded by remember { mutableStateOf(false) } + fun applyStopToRole(role: RouteStopRole, stop: RouteStop) { val next = when (role) { RouteStopRole.START -> plan.copy(start = stop) @@ -147,11 +192,8 @@ fun RoutePlannerSheet( // werden die Aktionsbuttons unter der Gestenleiste/Soft-Keys // versteckt — siehe Nutzerfeedback 2026-05-23). // - Der Hauptinhalt scrollt vertikal, damit auch auf kleinen - // Displays alle Bereiche (Endpunkt, Start, Via, Ziel, Anzeige, - // Status) erreichbar bleiben. - // - Primäre Aktionen ('Route berechnen' / 'Planlinie') liegen jetzt - // in einer Sticky Bottom Action Bar oberhalb der scrollbaren - // Detailansicht, sodass sie nie aus dem Sichtfeld rutschen. + // Displays alle Bereiche erreichbar bleiben. + // - Primäre Aktionen liegen jetzt in einer Sticky Bottom Action Bar. ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState @@ -168,7 +210,8 @@ fun RoutePlannerSheet( .padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(22.dp)) + Icon(Icons.Filled.Timeline, contentDescription = null, modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -178,7 +221,9 @@ fun RoutePlannerSheet( Text( stringResource(R.string.route_planner_subtitle), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } IconButton(onClick = onDismiss) { @@ -196,251 +241,284 @@ fun RoutePlannerSheet( .padding(top = 4.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - // Endpoint-Status-Karte - // v2.4.4: bei fehlendem Endpunkt erscheint hier ein klar - // sichtbarer Quick-Setup-Button („OSRM-Demo nutzen") plus - // Hinweis, dass dieser nur für Tests gedacht ist und Limits hat. - // Damit muss der Nutzer nicht erst in ein zweites Sheet wechseln, - // um Routing nutzen zu können. - Surface( - shape = RoundedCornerShape(12.dp), - color = if (routingEndpoint.isBlank()) - MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f) - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + + // ═══════════════════════════════════════════════════════════ + // PRIMÄR: Tourverlauf frei zeichnen + // Das erste und robusteste Werkzeug — kein Netz, kein Server. + // v2.7.24: Visuell als Primär-Card hervorgehoben (primary-Farbe), + // kein "Fallback"-Label. + // ═══════════════════════════════════════════════════════════ + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (routingEndpoint.isBlank()) Icons.Filled.Settings else Icons.Filled.AltRoute, - contentDescription = null, - modifier = Modifier.size(18.dp) + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Gesture, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.route_planner_draw_section_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + "Werkzeug 1", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + Text( + stringResource(R.string.route_planner_draw_section_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - if (routingEndpoint.isBlank()) - stringResource(R.string.route_planner_endpoint_not_configured) - else routingEndpoint, - style = MaterialTheme.typography.labelMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - stringResource(R.string.route_planner_attribution), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - TextButton(onClick = onConfigureEndpoint) { - Text(stringResource(R.string.route_planner_endpoint_configure)) - } - } - if (routingEndpoint.isBlank()) { Button( - onClick = onUseDemoEndpoint, + onClick = { onOpenDrawMode() }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ), modifier = Modifier.fillMaxWidth() ) { - Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp)) + Icon( + Icons.Filled.Gesture, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.route_planner_use_demo_endpoint)) + Text(stringResource(R.string.route_planner_draw_open_editor)) + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + + // ═══════════════════════════════════════════════════════════ + // SEKUNDÄR: Route über Punkte formen + // Start / Via / Ziel — Verlauf über definierte Punkte steuern. + // ═══════════════════════════════════════════════════════════ + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Route, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.route_planner_section_via), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + "Werkzeug 2", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } } Text( - stringResource(R.string.route_planner_use_demo_endpoint_hint), + stringResource(R.string.route_planner_shaping_section_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // START + Text( + stringResource(R.string.route_planner_section_start), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + RouteStopRow( + stop = plan.start, + roleLabel = stringResource(R.string.route_planner_role_start), + roleIcon = Icons.Filled.PinDrop, + placeholder = stringResource(R.string.route_planner_empty_start), + onChoose = { pickerForRole = RouteStopRole.START }, + onClear = { onPlanChange(plan.copy(start = null)) }, + showOrderControls = false + ) + + // VIA-Liste + Text( + stringResource(R.string.route_planner_section_via_points), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (plan.via.isEmpty()) { + Text( + stringResource(R.string.route_planner_empty_via), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + plan.via.forEachIndexed { idx, via -> + RouteStopRow( + stop = via, + roleLabel = stringResource(R.string.route_planner_role_via, idx + 1), + roleIcon = Icons.Filled.AddLocationAlt, + placeholder = "", + onChoose = null, + onClear = { + val list = plan.via.toMutableList() + list.removeAt(idx) + onPlanChange(plan.copy(via = list)) + }, + showOrderControls = true, + isFirst = idx == 0, + isLast = idx == plan.via.lastIndex, + onMoveUp = { + if (idx > 0) { + val list = plan.via.toMutableList() + val tmp = list[idx - 1]; list[idx - 1] = list[idx]; list[idx] = tmp + onPlanChange(plan.copy(via = list)) + } + }, + onMoveDown = { + if (idx < plan.via.lastIndex) { + val list = plan.via.toMutableList() + val tmp = list[idx + 1]; list[idx + 1] = list[idx]; list[idx] = tmp + onPlanChange(plan.copy(via = list)) + } + } + ) + } + } + } + // Aktionen: Punkt via Sheet-Picker oder Karten-Tap + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = { pickerForRole = RouteStopRole.VIA }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.AddLocationAlt, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_add_via)) + } + // v2.7.20 — Punkt auf Karte tippen + OutlinedButton( + onClick = { onStartAddViaOnMap() }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.route_planner_shaping_activate), + style = MaterialTheme.typography.labelSmall, + maxLines = 2 + ) + } + } + + // ZIEL + Text( + stringResource(R.string.route_planner_section_destination), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + RouteStopRow( + stop = plan.destination, + roleLabel = stringResource(R.string.route_planner_role_destination), + roleIcon = Icons.Filled.Flag, + placeholder = stringResource(R.string.route_planner_empty_destination), + onChoose = { pickerForRole = RouteStopRole.DESTINATION }, + onClear = { onPlanChange(plan.copy(destination = null)) }, + showOrderControls = false + ) + } + } + + // Layer-Anzeigeschalter + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Timeline, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.route_planner_layer_section), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelMedium + ) + androidx.compose.material3.Switch( + checked = showRouteOverlay, + onCheckedChange = onShowRouteOverlayChange + ) + } + Text( + stringResource(R.string.route_planner_layer_hint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - } else if (routingEndpoint.contains("router.project-osrm.org")) { - Text( - stringResource(R.string.routing_endpoint_demo_warning), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - // v2.5.2 — Routingprofil (Auto/Fahrrad/Fußgänger) - // - // Kompakter Segment-Switch im Look des v2.5.1-Designs: - // - Drei gleichwertige Pillen, aktive Auswahl mit Primary-Farbe. - // - Ändert den Plan-State (RoutePlan.profile) – die Route selbst - // wird beim Wechsel verworfen, damit die Karte nicht eine - // Auto-Route für eine Fußgänger-Auswahl zeigt. - Text( - stringResource(R.string.route_planner_profile_section), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - RoutingProfileSelector( - selected = plan.profile, - onSelectedChange = { newProfile -> - if (newProfile != plan.profile) { - onPlanChange(plan.copy(profile = newProfile)) - } - } - ) - Text( - stringResource(R.string.route_planner_profile_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - // START - Text( - stringResource(R.string.route_planner_section_start), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - RouteStopRow( - stop = plan.start, - roleLabel = stringResource(R.string.route_planner_role_start), - roleIcon = Icons.Filled.PinDrop, - placeholder = stringResource(R.string.route_planner_empty_start), - onChoose = { pickerForRole = RouteStopRole.START }, - onClear = { onPlanChange(plan.copy(start = null)) }, - showOrderControls = false - ) - - // VIA-Liste - Text( - stringResource(R.string.route_planner_section_via), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (plan.via.isEmpty()) { - Text( - stringResource(R.string.route_planner_empty_via), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - plan.via.forEachIndexed { idx, via -> - RouteStopRow( - stop = via, - roleLabel = stringResource(R.string.route_planner_role_via, idx + 1), - roleIcon = Icons.Filled.AddLocationAlt, - placeholder = "", - onChoose = null, - onClear = { - val list = plan.via.toMutableList() - list.removeAt(idx) - onPlanChange(plan.copy(via = list)) - }, - showOrderControls = true, - isFirst = idx == 0, - isLast = idx == plan.via.lastIndex, - onMoveUp = { - if (idx > 0) { - val list = plan.via.toMutableList() - val tmp = list[idx - 1]; list[idx - 1] = list[idx]; list[idx] = tmp - onPlanChange(plan.copy(via = list)) - } - }, - onMoveDown = { - if (idx < plan.via.lastIndex) { - val list = plan.via.toMutableList() - val tmp = list[idx + 1]; list[idx + 1] = list[idx]; list[idx] = tmp - onPlanChange(plan.copy(via = list)) - } + // v2.4.5: Legende spiegelt das tatsächliche Verhalten — + // berechnete Route ODER Planlinie, nicht beides parallel. + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp) + ) { + if (hasRoute) { + LineLegendChip( + color = 0xFF1976D2.toInt(), + label = stringResource(R.string.route_planner_legend_route), + solid = true + ) + } else if (hasPlanline) { + LineLegendChip( + color = 0xFFFF9800.toInt(), + label = stringResource(R.string.route_planner_legend_planline), + solid = false + ) + } else { + LineLegendChip( + color = 0xFF1976D2.toInt(), + label = stringResource(R.string.route_planner_legend_route), + solid = true + ) + LineLegendChip( + color = 0xFFFF9800.toInt(), + label = stringResource(R.string.route_planner_legend_planline), + solid = false + ) } - ) - } - } - } - TextButton(onClick = { pickerForRole = RouteStopRole.VIA }) { - Icon(Icons.Filled.AddLocationAlt, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.route_planner_add_via)) - } - - // ZIEL - Text( - stringResource(R.string.route_planner_section_destination), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - RouteStopRow( - stop = plan.destination, - roleLabel = stringResource(R.string.route_planner_role_destination), - roleIcon = Icons.Filled.Flag, - placeholder = stringResource(R.string.route_planner_empty_destination), - onChoose = { pickerForRole = RouteStopRole.DESTINATION }, - onClear = { onPlanChange(plan.copy(destination = null)) }, - showOrderControls = false - ) - - // Layer-Anzeigeschalter - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.Timeline, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(8.dp)) - Text( - stringResource(R.string.route_planner_layer_section), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.labelMedium - ) - androidx.compose.material3.Switch( - checked = showRouteOverlay, - onCheckedChange = onShowRouteOverlayChange - ) - } - Text( - stringResource(R.string.route_planner_layer_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - // v2.4.5: Legende spiegelt das tatsächliche Verhalten — - // berechnete Route ODER Planlinie, nicht beides parallel. - // Solange noch keine Route/Planlinie existiert, zeigen wir - // beide Chips zur Orientierung. - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 4.dp) - ) { - if (hasRoute) { - LineLegendChip( - color = 0xFF1976D2.toInt(), - label = stringResource(R.string.route_planner_legend_route), - solid = true - ) - } else if (hasPlanline) { - LineLegendChip( - color = 0xFFFF9800.toInt(), - label = stringResource(R.string.route_planner_legend_planline), - solid = false - ) - } else { - LineLegendChip( - color = 0xFF1976D2.toInt(), - label = stringResource(R.string.route_planner_legend_route), - solid = true - ) - LineLegendChip( - color = 0xFFFF9800.toInt(), - label = stringResource(R.string.route_planner_legend_planline), - solid = false - ) } } } - } if (routeStatus != null) { Text( @@ -456,11 +534,199 @@ fun RoutePlannerSheet( ) } + // ═══════════════════════════════════════════════════════════ + // EXPERIMENTELL / NACHRANGIG: Online-Routing + // v2.7.24: Eingeklappter Bereich — abhängig von externem Dienst, + // offline nicht nötig. Routing-Endpunkt, Profil und + // "Route berechnen" sind hier gebündelt. + // ═══════════════════════════════════════════════════════════ + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header-Zeile: klickbar zum Auf-/Zuklappen + TextButton( + onClick = { onlineRoutingExpanded = !onlineRoutingExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.Science, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.route_planner_online_routing_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + stringResource(R.string.route_planner_online_routing_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + if (onlineRoutingExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Aufklappbarer Inhalt + AnimatedVisibility( + visible = onlineRoutingExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Endpoint-Status-Karte + // v2.4.4: bei fehlendem Endpunkt erscheint hier ein klar + // sichtbarer Quick-Setup-Button plus Hinweis. + // v2.6.1 Layout-Fix: URL-Text und Konfigurieren-Button getrennt. + Surface( + shape = RoundedCornerShape(10.dp), + color = if (routingEndpoint.isBlank()) + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + if (routingEndpoint.isBlank()) Icons.Filled.Settings else Icons.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + // v2.6.1: weight(1f) stellt sicher, dass der Text die + // volle verbleibende Breite nutzt und nie auf 0 gedrückt wird. + Column(modifier = Modifier.weight(1f)) { + Text( + if (routingEndpoint.isBlank()) + stringResource(R.string.route_planner_endpoint_not_configured) + else routingEndpoint, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(R.string.route_planner_attribution), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + // v2.6.1: Konfigurieren-Button in eigene Zeile + TextButton( + onClick = onConfigureEndpoint, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Settings, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.route_planner_endpoint_configure), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (routingEndpoint.isBlank()) { + Button( + onClick = onUseDemoEndpoint, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_use_demo_endpoint)) + } + Text( + stringResource(R.string.route_planner_use_demo_endpoint_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (routingEndpoint.contains("router.project-osrm.org")) { + Text( + stringResource(R.string.routing_endpoint_demo_warning), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + // v2.5.2 — Routingprofil (Auto/Fahrrad/Fußgänger) + Text( + stringResource(R.string.route_planner_profile_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + RoutingProfileSelector( + selected = plan.profile, + onSelectedChange = { newProfile -> + if (newProfile != plan.profile) { + onPlanChange(plan.copy(profile = newProfile)) + } + } + ) + Text( + stringResource(R.string.route_planner_profile_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // "Online-Route berechnen" — jetzt im experimentellen Bereich + OutlinedButton( + onClick = onComputeRoute, + enabled = plan.isComplete && !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } else { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + } + Text(stringResource(R.string.route_planner_compute)) + } + } + } + } + } + // v2.4.6 — Tourrouten-Status & Aktionen - // - Zeigt klar an, ob eine Tourroute für die aktuelle Tour - // gespeichert ist (inkl. Distanz/Dauer/Aktualisierungszeit). - // - Aktionen: Speichern (wenn berechnete Route da, aber noch keine - // gespeicherte), Ersetzen (überschreibt explizit) und Löschen. if (currentTourName != null) { TourRouteStatusCard( tourName = currentTourName, @@ -473,9 +739,7 @@ fun RoutePlannerSheet( ) } - // Storyboard/Tour-Integration — bleibt im Scroll-Bereich, - // weil sie nur bei vollständigem Plan erscheint und kein - // Primärziel jedes Aufrufs ist. + // Storyboard/Tour-Integration if (plan.isComplete) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( @@ -505,9 +769,7 @@ fun RoutePlannerSheet( } } - // Footer-Aktionen (Löschen / Schließen) — bleiben im - // Scroll-Bereich, damit die Sticky-Bar unten nur die - // Hauptaktionen 'Route berechnen' / 'Planlinie' enthält. + // Footer-Aktionen (Löschen / Schließen) Row(modifier = Modifier.fillMaxWidth()) { TextButton(onClick = onClear, modifier = Modifier.weight(1f)) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) @@ -520,9 +782,10 @@ fun RoutePlannerSheet( } } // Ende Scroll-Column - // Sticky Bottom Action Bar — liegt oberhalb der Systemnavigation - // (navigationBarsPadding) und enthält die primären Aktionen, - // damit sie nie aus dem Sichtfeld rutschen. + // Sticky Bottom Action Bar + // v2.7.24: Vereinfacht — primäre Aktion "Als Tourverlauf übernehmen", + // sekundär "Verlaufslinie anzeigen". Online-Route berechnen ist jetzt + // im experimentellen Bereich oben und erscheint nicht mehr hier. Surface( color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp, @@ -535,47 +798,37 @@ fun RoutePlannerSheet( .padding(horizontal = 16.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - // v2.4.4: Button bleibt klickbar, sobald der Plan - // vollständig ist — auch wenn kein Endpunkt konfiguriert - // ist. Der Aufrufer (Explorer) zeigt in diesem Fall - // einen klaren Status mit Verweis auf den Demo-Quick- - // Setup oder die Endpunkt-Einrichtung. So vermeiden wir - // den toten/grauen Button ohne sichtbaren Grund. - Button( - onClick = onComputeRoute, - enabled = plan.isComplete && !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - modifier = Modifier.weight(1f) - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(Modifier.width(8.dp)) - } else { - Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - } - Text(stringResource(R.string.route_planner_compute)) - } - Button( + // v2.7.23: "Als Tourverlauf übernehmen" als primäre Aktion. + // v2.7.24: Alleinige primäre Aktion in der Sticky Bar. + Button( + onClick = onAdoptAsTourRoute, + enabled = (hasPlanline || hasRoute) && !isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.route_planner_adopt_planline), + style = MaterialTheme.typography.labelMedium + ) + } + // Sekundär: Verlaufslinie anzeigen (nur wenn Start+Ziel gesetzt) + if (plan.isComplete) { + OutlinedButton( onClick = onShowPlanline, - enabled = plan.isComplete && !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - modifier = Modifier.weight(1f) + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Timeline, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.route_planner_planline)) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.route_planner_planline), + style = MaterialTheme.typography.labelMedium + ) } } } @@ -1150,7 +1403,7 @@ private fun SaveAsTourDialog( }, confirmButton = { TextButton( - onClick = { onConfirm(name.trim().ifBlank { "Routenentwurf" }) } + onClick = { onConfirm(name.trim().ifBlank { "Tourentwurf" }) } ) { Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) @@ -1174,6 +1427,9 @@ private fun SaveAsTourDialog( * - Ersetzen: überschreibt eine vorhandene gespeicherte Route mit der * aktuell sichtbaren (verhindert versehentliches Überschreiben). * - Löschen: entfernt die gespeicherte Tourroute. + * + * v2.6.1: tour_route_source-Text (enthält ggf. lange URL) mit maxLines=1 + * und Ellipsis, damit langer Endpunkt-Name nicht umbricht. */ @Composable private fun TourRouteStatusCard( @@ -1233,11 +1489,13 @@ private fun TourRouteStatusCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) if (savedRoute.endpoint.isNotBlank()) { + // v2.6.1: maxLines=1 + Ellipsis — langer Endpunkt-URL + // bricht nicht mehr vertikal um Text( stringResource(R.string.tour_route_source, savedRoute.endpoint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -1289,12 +1547,19 @@ private fun TourRouteStatusCard( } } } + // v2.7.23 — Zeige Hinweis wann Speichern möglich ist if (!hasUsableLive && savedRoute == null) { Text( stringResource(R.string.tour_route_hint_compute_first), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + } else if (hasUsableLive && savedRoute == null) { + Text( + stringResource(R.string.route_planner_adopt_planline_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f) + ) } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/TourverlaufEditorSheet.kt b/app/src/main/kotlin/de/waypointaudio/ui/TourverlaufEditorSheet.kt new file mode 100644 index 0000000..4d1d44a --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/TourverlaufEditorSheet.kt @@ -0,0 +1,456 @@ +package de.waypointaudio.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddLocation +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HeadsetMic +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Undo +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import org.maplibre.android.geometry.LatLng + +/** + * v2.7.0 — Tourverlauf-Editor-BottomSheet. + * v2.7.17 — Route-first: Sprache: Routenpunkte statt Verlaufspunkte, + * "Audio hier hinzufügen" statt technischem "Als Audio-Wegpunkt erstellen". + * Keine Logik-Änderungen. + * + * Klarer Bearbeitungsmodus für den Tourverlauf: + * - Verlaufspunkte direkt auf der Karte setzen (Tipp-Modus oder Kartenmitte) + * - Punkte per Dialog entfernen/verschieben (bestehende Logik) + * - "Als Audio-Wegpunkt erstellen" für einen Verlaufspunkt + * - Letzten Punkt entfernen / Verlauf komplett löschen + * - Einklappbarer "Optional: Online-Route berechnen"-Abschnitt (→ öffnet RoutePlannerSheet) + * + * Wiederverwendete Bausteine: + * - manualRoutePoints / TourRoute / RouteSourceKind.MANUAL (Persistenz, Auto-Save) + * - WaypointEditDialog (Audio-Wegpunkt erstellen) + * - RoutePlannerSheet (Online-Routing, optional/einklappbar) + * + * Keine Regressionen: Logik, Playback, Backup, Lizenz, GPS-Aufzeichnung unverändert. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TourverlaufEditorSheet( + /** Aktuelle Verlaufspunkte (manualRoutePoints). */ + routePoints: List, + /** True wenn Tipp-Modus für neuen Punkt aktiv. */ + isDrawing: Boolean, + /** Name der ausgewählten Tour (für Kontext-Labels). */ + tourName: String, + /** Letzter bekannter Verlaufspunkt, aus dem ein Audio-Wegpunkt erstellt werden soll. */ + lastSelectedPointIndex: Int?, + + // Callbacks + /** Punkt an der Kartenmitte hinzufügen. */ + onAddCenterPoint: () -> Unit, + /** Tipp-Modus zum Setzen per Kartentap umschalten. */ + onToggleDrawMode: () -> Unit, + /** Letzten Punkt entfernen. */ + onRemoveLast: () -> Unit, + /** Gesamten Verlauf löschen. */ + onClear: () -> Unit, + /** Für einen Verlaufspunkt einen Audio-Wegpunkt erstellen. */ + onCreateWaypointFromPoint: (LatLng) -> Unit, + /** Online-Routing öffnen (RoutePlannerSheet). */ + onOpenOnlineRouting: () -> Unit, + /** Sheet schließen. */ + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + var showClearConfirm by remember { mutableStateOf(false) } + var onlineRoutingExpanded by remember { mutableStateOf(false) } + + val pointCount = routePoints.size + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // ─── Header ─────────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.Edit, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + stringResource(R.string.tourverlauf_editor_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + // v2.7.15 — Untertitel: Beschreibung des konsolidierten Einstiegs + Text( + stringResource(R.string.tourverlauf_menu_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + if (tourName.isNotBlank()) { + Text( + tourName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f) + ) + } + } + } + IconButton(onClick = onDismiss) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.tourverlauf_editor_close)) + } + } + + // ─── Erklärtext ─────────────────────────────────────────────── + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.size(16.dp).padding(top = 2.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + ) + Text( + stringResource(R.string.tourverlauf_editor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + // ─── Punkt-Zähler ───────────────────────────────────────────── + Surface( + shape = RoundedCornerShape(12.dp), + color = if (pointCount > 0) + MaterialTheme.colorScheme.tertiaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + if (pointCount > 0) Icons.Filled.CheckCircle else Icons.Filled.PinDrop, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (pointCount > 0) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + if (pointCount > 0) + stringResource(R.string.tourverlauf_editor_point_count, pointCount) + else + stringResource(R.string.tourverlauf_editor_no_points), + style = MaterialTheme.typography.bodyMedium, + color = if (pointCount > 0) + MaterialTheme.colorScheme.onTertiaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + + // ─── Punkt setzen ───────────────────────────────────────────── + Text( + "Routenpunkt hinzufügen", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + modifier = Modifier.padding(top = 4.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Kartenmitte-Button + Button( + onClick = { + onAddCenterPoint() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.tourverlauf_editor_add_center)) + } + + // Tipp-Modus-Toggle + Button( + onClick = onToggleDrawMode, + colors = ButtonDefaults.buttonColors( + containerColor = if (isDrawing) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.secondaryContainer, + contentColor = if (isDrawing) + MaterialTheme.colorScheme.onTertiary + else + MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.TouchApp, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text( + if (isDrawing) "Tipp-Modus aktiv" else stringResource(R.string.tourverlauf_editor_add_tap) + ) + } + } + + // ─── Aktionen ───────────────────────────────────────────────── + if (pointCount > 0) { + Text( + "Tourpunkt-Aktionen", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + modifier = Modifier.padding(top = 4.dp) + ) + + // Als Audio-Wegpunkt erstellen + val targetPoint = lastSelectedPointIndex?.let { routePoints.getOrNull(it) } + ?: routePoints.lastOrNull() + if (targetPoint != null) { + OutlinedButton( + onClick = { onCreateWaypointFromPoint(targetPoint) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.HeadsetMic, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.tourverlauf_editor_as_waypoint)) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Letzten Punkt entfernen + OutlinedButton( + onClick = onRemoveLast, + enabled = pointCount > 0, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.secondary + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Undo, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.tourverlauf_editor_remove_last)) + } + + // Verlauf löschen + OutlinedButton( + onClick = { showClearConfirm = true }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.tourverlauf_editor_clear)) + } + } + } + + // ─── Optional: Online-Routing ────────────────────────────────── + Divider(modifier = Modifier.padding(vertical = 4.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth() + ) { + Column { + // Header — einklappbar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + Text( + stringResource(R.string.tourverlauf_online_routing_section), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton( + onClick = { onlineRoutingExpanded = !onlineRoutingExpanded }, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (onlineRoutingExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (onlineRoutingExpanded) "Schließen" else "Aufklappen", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + AnimatedVisibility( + visible = onlineRoutingExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.tourverlauf_online_routing_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + OutlinedButton( + onClick = { + onlineRoutingExpanded = false + onOpenOnlineRouting() + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_open)) + } + } + } + } + } + + Spacer(Modifier.height(8.dp)) + } + } + + // Lösch-Bestätigungsdialog + if (showClearConfirm) { + AlertDialog( + onDismissRequest = { showClearConfirm = false }, + title = { Text(stringResource(R.string.tourverlauf_editor_clear_confirm)) }, + confirmButton = { + Button( + onClick = { + onClear() + showClearConfirm = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { showClearConfirm = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt index 3c1cfa1..303c860 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt @@ -82,7 +82,7 @@ fun VersionNoticeDialogHost() { fontWeight = FontWeight.SemiBold ) Text( - text = stringResource(R.string.version_notice_body_v2538), + text = stringResource(R.string.version_notice_body_v2720), style = MaterialTheme.typography.bodyMedium ) Row( diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt index c1d4304..b181ad4 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt @@ -7,8 +7,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import java.util.Calendar import java.util.Locale +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -18,6 +21,15 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.AudioFile import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.* @@ -29,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import de.waypointaudio.R import de.waypointaudio.data.AudioClip @@ -61,6 +74,15 @@ private fun Int?.toDisplayTimeString(): String { /** * Dialog for creating and editing a waypoint. * + * v2.6.1 — UI-Polish: + * - Klar gegliederte Abschnitte als Cards (Basis, Position, Audio, Auslösung, Wiedergabe, Zeitplan). + * - Audio-Bereich prominenter: freundlicher Empty-State mit Icon + Aufruf-Text. + * - Radius-Label „Auslösebereich" statt „Radius (Meter)". + * - Pflichtfeld-Fehler dezenter (kein aggressives Pink), Validation identisch. + * - Technische Hinweistexte (Dezimalgrad) reduziert und als sekundäre Info. + * - GPS-Vorausfüll-Hinweis freundlicher formuliert. + * - Keine Logik-Änderungen: alle Felder, Callbacks, Validation, Clips, Lizenz-Gate. + * * @param waypoint Existing waypoint (null = create new) * @param prefillLatLng Pre-filled GPS coordinates (lat, lon). * Only used for new waypoints (waypoint == null). @@ -92,7 +114,6 @@ fun WaypointEditDialog( val isNew = waypoint == null // v2.5.32 — Feature-Gate: Multi-Clip-Waypoints erfordern Profi-Zugang. - // Der Wert liest den Compose-State direkt, reagiert reaktiv auf Lizenzimport. val licenseStatus = LicenseStatusProvider.status val canMultiClip = LicenseFeatureAccess.canUseMultiClip(licenseStatus) @@ -115,18 +136,14 @@ fun WaypointEditDialog( var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") } // v2.5.12 — Multi-Clip-Editor. - // Initial aus effectiveClips() befüllen, sodass alte Wegpunkte ohne audioClips - // ihren Legacy-soundUri als ersten Clip vorfinden. val clips = remember { mutableStateListOf().apply { waypoint?.effectiveClips()?.let { addAll(it) } } } - // Index des Clips, der durch den nächsten Picker-Treffer ersetzt werden soll. - // null = ein neuer Clip wird angehängt. var replaceTargetIndex by remember { mutableStateOf(null) } - // Tour name: use existing waypoint's tour, or prefill for new, fallback to default + // Tour name val initialTour = when { waypoint != null -> waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } prefillTourName.isNotBlank() -> prefillTourName @@ -135,37 +152,32 @@ fun WaypointEditDialog( var tourName by remember { mutableStateOf(initialTour) } var tourMenuExpanded by remember { mutableStateOf(false) } - // Build the selectable tour list: existing tours + current value if not yet in list val selectableTours = remember(existingTours, tourName) { val base = if (existingTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else existingTours if (tourName !in base) base + tourName else base } - // Basic field validation errors + // Validation errors var nameError by remember { mutableStateOf(false) } var latError by remember { mutableStateOf(false) } var lngError by remember { mutableStateOf(false) } var radiusError by remember { mutableStateOf(false) } - // --- Playback rules state – inherit tour defaults for new waypoints --- + // Playback rules state val defaultMode = if (waypoint == null) tourDefaults.playbackMode else waypoint.playbackMode val defaultMaxCount = if (waypoint == null && tourDefaults.playbackMode == PlaybackMode.LIMITED_COUNT) tourDefaults.maxPlayCount.toString() else waypoint?.maxPlayCount?.toString() ?: "" var playbackMode by remember { mutableStateOf(defaultMode) } - var maxPlayCountStr by remember { - mutableStateOf(defaultMaxCount) - } + var maxPlayCountStr by remember { mutableStateOf(defaultMaxCount) } var maxPlayCountError by remember { mutableStateOf(false) } var currentPlayCount by remember { mutableStateOf(waypoint?.playCount ?: 0) } - // Scheduler: stored as millis / minutes (nullable = not set) + // Scheduler var scheduleEnabled by remember { mutableStateOf(waypoint?.scheduleEnabled ?: false) } var scheduleStartMillis by remember { mutableStateOf(waypoint?.scheduleStartMillis) } var scheduleEndMillis by remember { mutableStateOf(waypoint?.scheduleEndMillis) } var allowedStartMinutes by remember { mutableStateOf(waypoint?.allowedStartMinutes) } var allowedEndMinutes by remember { mutableStateOf(waypoint?.allowedEndMinutes) } - - // Schedule validation errors var scheduleRangeError by remember { mutableStateOf(false) } // GPS pre-fill on change @@ -176,8 +188,7 @@ fun WaypointEditDialog( } } - // Audio file picker — v2.5.12: erzeugt einen neuen Clip oder ersetzt - // die Audio-Quelle eines bestehenden Clips. Steuerung über [replaceTargetIndex]. + // Audio file picker val audioPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> @@ -197,7 +208,6 @@ fun WaypointEditDialog( clips[tgt] = old.copy( uri = pickedUri, displayFileName = pickedName, - // Titel nur dann automatisch übernehmen, wenn noch keiner gesetzt war title = old.title.ifBlank { pickedName } ) } else { @@ -216,18 +226,13 @@ fun WaypointEditDialog( } } - // Helper: open DatePickerDialog followed by TimePickerDialog, result via callback - fun showDateTimePicker( - initialMillis: Long?, - onResult: (Long) -> Unit - ) { + fun showDateTimePicker(initialMillis: Long?, onResult: (Long) -> Unit) { val cal = Calendar.getInstance().also { c -> if (initialMillis != null) c.timeInMillis = initialMillis } DatePickerDialog( context, { _, year, month, day -> - // After date is picked, open time picker TimePickerDialog( context, { _, hour, minute -> @@ -244,7 +249,7 @@ fun WaypointEditDialog( }, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), - true // 24-hour format + true ).show() }, cal.get(Calendar.YEAR), @@ -253,20 +258,13 @@ fun WaypointEditDialog( ).show() } - // Helper: open TimePickerDialog, result in minutes (0..1439) - fun showTimePicker( - initialMinutes: Int?, - onResult: (Int) -> Unit - ) { + fun showTimePicker(initialMinutes: Int?, onResult: (Int) -> Unit) { val h = (initialMinutes ?: 0) / 60 val m = (initialMinutes ?: 0) % 60 TimePickerDialog( context, - { _, hour, minute -> - onResult(hour * 60 + minute) - }, - h, m, - true // 24-hour format + { _, hour, minute -> onResult(hour * 60 + minute) }, + h, m, true ).show() } @@ -274,7 +272,8 @@ fun WaypointEditDialog( onDismissRequest = onDismiss, title = { Text( - stringResource(if (isNew) R.string.add_waypoint else R.string.edit_waypoint) + stringResource(if (isNew) R.string.add_waypoint else R.string.edit_waypoint), + style = MaterialTheme.typography.titleLarge ) }, text = { @@ -282,704 +281,747 @@ fun WaypointEditDialog( modifier = Modifier .verticalScroll(rememberScrollState()) .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - // GPS pre-fill notice + + // --------------------------------------------------------------- + // GPS-Vorausfüll-Hinweis (freundlicher) + // --------------------------------------------------------------- if (isNew && prefillLatLng != null) { Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = MaterialTheme.shapes.small + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth() ) { - Text( - "📍 Koordinaten aus aktuellem GPS-Standort vorausgefüllt.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.GpsFixed, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + "GPS-Standort als Position vorausgefüllt", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } } - // Tour-Vorgaben-Hinweis für neue Wegpunkte + + // Tour-Vorgaben-Hinweis if (isNew && tourDefaults.playbackMode != de.waypointaudio.data.PlaybackMode.EVERY_ENTRY) { Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.small + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth() ) { Text( text = stringResource(R.string.waypoint_inherits_tour_defaults), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) ) } } - // Name - OutlinedTextField( - value = name, - onValueChange = { name = it; nameError = false }, - label = { Text(stringResource(R.string.waypoint_name)) }, - isError = nameError, - supportingText = if (nameError) {{ Text("Pflichtfeld") }} else null, - singleLine = true, - modifier = Modifier.fillMaxWidth() + // --------------------------------------------------------------- + // STATUS-/KOPFKARTE: Name, Tour, Kurzüberblick (Position/Audio) + // --------------------------------------------------------------- + WaypointHeaderCard( + name = name, + tourName = tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }, + positionSummary = positionSummary(latStr, lngStr), + audioSummary = audioSummary(clips.size) ) // --------------------------------------------------------------- - // Tour-Zuordnung + // ABSCHNITT 1: Details (Name + Tour) – immer sichtbar // --------------------------------------------------------------- - Text( - text = stringResource(R.string.tour_section_label), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) + WaypointSectionCard { + WaypointSectionLabel( + icon = Icons.Filled.Place, + text = "Details" + ) + Spacer(Modifier.height(8.dp)) - // Tour dropdown – selectableTours als Auswahlhilfe, - // aber freie Texteingabe ist ebenfalls möglich - ExposedDropdownMenuBox( - expanded = tourMenuExpanded, - onExpandedChange = { tourMenuExpanded = it } - ) { + // Name (prominent) OutlinedTextField( - value = tourName, - onValueChange = { tourName = it }, - label = { Text(stringResource(R.string.tour_name_label)) }, - placeholder = { Text(Waypoint.DEFAULT_TOUR_NAME) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = tourMenuExpanded) - }, + value = name, + onValueChange = { name = it; nameError = false }, + label = { Text(stringResource(R.string.waypoint_name)) }, + isError = nameError, + supportingText = if (nameError) { + { Text("Name ist erforderlich", color = MaterialTheme.colorScheme.error) } + } else null, singleLine = true, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = tourMenuExpanded, - onDismissRequest = { tourMenuExpanded = false } - ) { - selectableTours.forEach { t -> - DropdownMenuItem( - text = { Text(t) }, - onClick = { - tourName = t - tourMenuExpanded = false - } - ) - } - } - } - Text( - text = stringResource(R.string.tour_name_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) - ) - - // --------------------------------------------------------------- - // v2.5.16 — Wegpunkt kopieren (nur im Bearbeiten-Modus sichtbar) - // Direkt unter der Tour-Zuordnung, damit der Aktionsbereich - // ohne Scrollen auffindbar ist. Wird nur gezeigt, wenn der - // Aufrufer einen Copy-Callback liefert (Player/Playback nicht). - // --------------------------------------------------------------- - if (waypoint != null && onCopyToOtherTour != null) { - WaypointCopySection( - currentTour = tourName, - existingTours = existingTours, - onCopy = { target -> - onCopyToOtherTour(target) - onDismiss() - } - ) - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - - // Coordinates - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = latStr, - onValueChange = { latStr = it; latError = false }, - label = { Text("Lat") }, - isError = latError, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = lngStr, - onValueChange = { lngStr = it; lngError = false }, - label = { Text("Lng") }, - isError = lngError, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - - // Coordinates format hint - Text( - "Dezimalgrad, z. B. 48.137154 · 11.576124", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) - ) - - // Radius - OutlinedTextField( - value = radiusStr, - onValueChange = { radiusStr = it; radiusError = false }, - label = { Text(stringResource(R.string.waypoint_radius)) }, - isError = radiusError, - supportingText = if (radiusError) {{ Text("Ganzzahl > 0") }} else null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - // --------------------------------------------------------------- - // v2.5.12 — Audio-Clips (Mehrfach-Auswahl + Reihenfolge) - // v2.5.32 — Multi-Clip-Gate: Hauptclip (idx 0) immer frei, - // Zusatzclips (idx > 0) nur mit Profi-Zugang bearbeiten. - // --------------------------------------------------------------- - Text( - text = "Audio-Clips", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - - // v2.5.32 — Profi-Hinweis: nur anzeigen, wenn Zusatzclips vorhanden - // und kein Profi-Zugang. Daten werden nicht gelöscht. - if (!canMultiClip && clips.size > 1) { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.small, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(6.dp)) + + // Tour-Dropdown + ExposedDropdownMenuBox( + expanded = tourMenuExpanded, + onExpandedChange = { tourMenuExpanded = it } ) { + OutlinedTextField( + value = tourName, + onValueChange = { tourName = it }, + label = { Text(stringResource(R.string.tour_name_label)) }, + placeholder = { Text(Waypoint.DEFAULT_TOUR_NAME) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = tourMenuExpanded) + }, + singleLine = true, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = tourMenuExpanded, + onDismissRequest = { tourMenuExpanded = false } + ) { + selectableTours.forEach { t -> + DropdownMenuItem( + text = { Text(t) }, + onClick = { + tourName = t + tourMenuExpanded = false + } + ) + } + } + } + Text( + text = stringResource(R.string.tour_name_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // --------------------------------------------------------------- + // ABSCHNITT 2: Position (einklappbar – nur Koordinaten bei Bedarf) + // --------------------------------------------------------------- + WaypointExpandableSection( + icon = Icons.Filled.MyLocation, + title = "Position", + summary = positionSummary(latStr, lngStr), + initiallyExpanded = latError || lngError, + highlight = latError || lngError + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = latStr, + onValueChange = { latStr = it; latError = false }, + label = { Text("Breitengrad") }, + isError = latError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = lngStr, + onValueChange = { lngStr = it; lngError = false }, + label = { Text("Längengrad") }, + isError = lngError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + if (latError || lngError) { Text( - text = LicenseFeatureAccess.MSG_MULTI_CLIP, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + "Gültige Koordinaten eingeben (z. B. 48.137 · 11.576)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } else { + Text( + "Dezimalgrad · z. B. 48.137154 / 11.576124", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } - if (clips.isEmpty()) { - Text( - text = stringResource(R.string.no_sound_selected), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) - ) - } else { - clips.forEachIndexed { idx, clip -> - // v2.5.32: Zusatzclips (idx > 0) sind ohne Profi-Zugang - // lesbar (Daten bleiben erhalten), aber nicht bearbeitbar. - val isExtraClip = idx > 0 - val clipEditable = !isExtraClip || canMultiClip + // --------------------------------------------------------------- + // ABSCHNITT 3: Audio-Clips (einklappbar) + // --------------------------------------------------------------- + WaypointExpandableSection( + icon = Icons.Filled.MusicNote, + title = "Audio", + summary = audioSummary(clips.size), + initiallyExpanded = clips.isEmpty() + ) { + // v2.5.32 — Profi-Hinweis + if (!canMultiClip && clips.size > 1) { Surface( - tonalElevation = 1.dp, - shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f), + shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth() ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Kopfzeile: Position + (primary)-Marker - Row( - verticalAlignment = Alignment.CenterVertically, + Text( + text = LicenseFeatureAccess.MSG_MULTI_CLIP, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) + } + Spacer(Modifier.height(4.dp)) + } + + if (clips.isEmpty()) { + // Freundlicher Empty-State + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Filled.AudioFile, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + "Noch kein Audio-Clip hinzugefügt", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + Text( + "Wähle eine Audio-Datei, die am Wegpunkt abgespielt wird.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + clips.forEachIndexed { idx, clip -> + val isExtraClip = idx > 0 + val clipEditable = !isExtraClip || canMultiClip + Surface( + tonalElevation = 2.dp, + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Clip ${idx + 1}" + if (clip.isPrimary) " · Hauptclip" else "", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.weight(1f) - ) - if (clipEditable) { - // Reihenfolge nach oben / unten - IconButton( - onClick = { - if (idx > 0) { - val tmp = clips[idx - 1] - clips[idx - 1] = clips[idx] - clips[idx] = tmp - } - }, - enabled = idx > 0 - ) { - Icon( - Icons.Filled.ArrowUpward, - contentDescription = "Nach oben", - modifier = Modifier.size(18.dp) - ) - } - IconButton( - onClick = { - if (idx < clips.size - 1) { - val tmp = clips[idx + 1] - clips[idx + 1] = clips[idx] - clips[idx] = tmp - } - }, - enabled = idx < clips.size - 1 - ) { - Icon( - Icons.Filled.ArrowDownward, - contentDescription = "Nach unten", - modifier = Modifier.size(18.dp) - ) - } - } - } - - // Titel-Feld - OutlinedTextField( - value = clip.title, - onValueChange = { newTitle -> - if (clipEditable) clips[idx] = clip.copy(title = newTitle) - }, - label = { Text("Titel") }, - placeholder = { Text(clip.displayFileName.ifBlank { "Audio-Clip" }) }, - singleLine = true, - readOnly = !clipEditable, - modifier = Modifier.fillMaxWidth() - ) - - // Dateiname (Quelle) als kleiner Hinweis - if (clip.displayFileName.isNotBlank()) { - Text( - text = clip.displayFileName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), - maxLines = 2 - ) - } else if (!clip.hasAudio) { - Text( - text = "Keine Audio-Datei zugewiesen", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - if (clipEditable) { - // Aktions-Zeile - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - // Aktiv-Schalter - Text( - text = "Aktiv", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f) - ) - Switch( - checked = clip.isActive, - onCheckedChange = { checked -> - clips[idx] = clip.copy(isActive = checked) - } - ) - // Audio ersetzen - IconButton(onClick = { - replaceTargetIndex = idx - audioPickerLauncher.launch(arrayOf("audio/*")) - }) { + // Kopfzeile + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { Icon( - Icons.Filled.SwapHoriz, - contentDescription = "Audio ersetzen", - modifier = Modifier.size(20.dp) + Icons.Filled.AudioFile, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) ) - } - // Clip löschen - IconButton(onClick = { - clips.removeAt(idx) - }) { - Icon( - Icons.Filled.Delete, - contentDescription = "Clip löschen", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.error - ) - } - } - - // Als Hauptclip markieren (nur sinnvoll, wenn noch nicht primary) - if (!clip.isPrimary && clip.hasAudio) { - TextButton(onClick = { - // Alle Clips zurücksetzen, dann diesen markieren - for (i in clips.indices) { - clips[i] = clips[i].copy(isPrimary = (i == idx)) - } - }) { + Spacer(Modifier.width(4.dp)) Text( - text = "Als Hauptclip festlegen", - style = MaterialTheme.typography.bodySmall + text = "Clip ${idx + 1}" + if (clip.isPrimary) " · Hauptclip" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + if (clipEditable) { + IconButton( + onClick = { + if (idx > 0) { + val tmp = clips[idx - 1] + clips[idx - 1] = clips[idx] + clips[idx] = tmp + } + }, + enabled = idx > 0, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.ArrowUpward, + contentDescription = "Nach oben", + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = { + if (idx < clips.size - 1) { + val tmp = clips[idx + 1] + clips[idx + 1] = clips[idx] + clips[idx] = tmp + } + }, + enabled = idx < clips.size - 1, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.ArrowDownward, + contentDescription = "Nach unten", + modifier = Modifier.size(16.dp) + ) + } + } + } + + // Titel-Feld + OutlinedTextField( + value = clip.title, + onValueChange = { newTitle -> + if (clipEditable) clips[idx] = clip.copy(title = newTitle) + }, + label = { Text("Clip-Titel") }, + placeholder = { Text(clip.displayFileName.ifBlank { "Audio-Clip" }) }, + singleLine = true, + readOnly = !clipEditable, + modifier = Modifier.fillMaxWidth() + ) + + // Dateiname + if (clip.displayFileName.isNotBlank()) { + Text( + text = clip.displayFileName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else if (!clip.hasAudio) { + Text( + text = "Keine Audio-Datei zugewiesen", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error ) } + + if (clipEditable) { + // Aktions-Zeile + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Aktiv", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f) + ) + Switch( + checked = clip.isActive, + onCheckedChange = { checked -> + clips[idx] = clip.copy(isActive = checked) + } + ) + IconButton(onClick = { + replaceTargetIndex = idx + audioPickerLauncher.launch(arrayOf("audio/*")) + }) { + Icon( + Icons.Filled.SwapHoriz, + contentDescription = "Audio ersetzen", + modifier = Modifier.size(20.dp) + ) + } + IconButton(onClick = { + clips.removeAt(idx) + }) { + Icon( + Icons.Filled.Delete, + contentDescription = "Clip löschen", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) + } + } + + if (!clip.isPrimary && clip.hasAudio) { + TextButton(onClick = { + for (i in clips.indices) { + clips[i] = clips[i].copy(isPrimary = (i == idx)) + } + }) { + Text( + text = "Als Hauptclip festlegen", + style = MaterialTheme.typography.labelSmall + ) + } + } + } } - } // end clipEditable + } } } } + + Spacer(Modifier.height(6.dp)) + + // Audio-Hinzufügen-Button (prominent) + if (canMultiClip || clips.isEmpty()) { + Button( + onClick = { + replaceTargetIndex = null + audioPickerLauncher.launch(arrayOf("audio/*")) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (clips.isEmpty()) "Audio-Clip auswählen" + else "Weiteren Clip hinzufügen" + ) + } + } else if (clips.isNotEmpty()) { + OutlinedButton( + onClick = { + replaceTargetIndex = 0 + audioPickerLauncher.launch(arrayOf("audio/*")) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Hauptclip-Audio wechseln") + } + } } - // "Neuen Clip hinzufügen" – startet Picker im Anhängen-Modus. - // v2.5.32: Nur anzeigen wenn Profi-Zugang vorhanden ODER noch kein - // Clip (Hauptclip hinzufügen ist immer frei). - if (canMultiClip || clips.isEmpty()) { - OutlinedButton( - onClick = { - replaceTargetIndex = null - audioPickerLauncher.launch(arrayOf("audio/*")) - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text( - if (clips.isEmpty()) stringResource(R.string.choose_sound) - else "Weiteren Clip hinzufügen" - ) - } - } else if (clips.isNotEmpty()) { - // Hauptclip-Audio-Wechsel bleibt immer möglich (index 0) - OutlinedButton( - onClick = { - replaceTargetIndex = 0 - audioPickerLauncher.launch(arrayOf("audio/*")) - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - Icons.Filled.SwapHoriz, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Hauptclip-Audio wechseln") - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - // --------------------------------------------------------------- - // Playback rules + // ABSCHNITT 4: Auslösung (Radius, einklappbar) // --------------------------------------------------------------- - Text( - text = stringResource(R.string.playback_rules_section), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - - // Mode selection via DropdownMenu - var modeMenuExpanded by remember { mutableStateOf(false) } - val modeLabel = when (playbackMode) { - PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry) - PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once) - PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited) - } - ExposedDropdownMenuBox( - expanded = modeMenuExpanded, - onExpandedChange = { modeMenuExpanded = it } + WaypointExpandableSection( + icon = Icons.Filled.RadioButtonChecked, + title = "Auslösung", + summary = radiusStr.trim().let { if (it.isBlank()) "—" else "$it m Auslösebereich" }, + initiallyExpanded = radiusError, + highlight = radiusError ) { OutlinedTextField( - value = modeLabel, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.playback_mode_label)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = modeMenuExpanded, - onDismissRequest = { modeMenuExpanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.playback_mode_every_entry)) }, - onClick = { - playbackMode = PlaybackMode.EVERY_ENTRY - modeMenuExpanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.playback_mode_once)) }, - onClick = { - playbackMode = PlaybackMode.ONCE - modeMenuExpanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.playback_mode_limited)) }, - onClick = { - playbackMode = PlaybackMode.LIMITED_COUNT - modeMenuExpanded = false - } - ) - } - } - - // Max count – only visible for LIMITED_COUNT - if (playbackMode == PlaybackMode.LIMITED_COUNT) { - OutlinedTextField( - value = maxPlayCountStr, - onValueChange = { maxPlayCountStr = it; maxPlayCountError = false }, - label = { Text(stringResource(R.string.playback_max_count_label)) }, - placeholder = { Text(stringResource(R.string.playback_max_count_hint)) }, - isError = maxPlayCountError, - supportingText = if (maxPlayCountError) { - { Text(stringResource(R.string.playback_max_count_error)) } + value = radiusStr, + onValueChange = { radiusStr = it; radiusError = false }, + label = { Text("Auslösebereich (Meter)") }, + isError = radiusError, + supportingText = if (radiusError) { + { Text("Ganzzahl > 0", color = MaterialTheme.colorScheme.error) } } else null, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, modifier = Modifier.fillMaxWidth() ) + Text( + "Audio wird ausgelöst, sobald du diesen Bereich betrittst.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - // Counter display + reset (edit mode only) - if (!isNew) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.playback_count_info, currentPlayCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - TextButton(onClick = { currentPlayCount = 0 }) { - Text( - text = stringResource(R.string.playback_count_reset), - style = MaterialTheme.typography.bodySmall - ) - } - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - // --------------------------------------------------------------- - // Scheduler + // ABSCHNITT 5: Wiedergabe-Regeln (einklappbar) // --------------------------------------------------------------- - Text( - text = stringResource(R.string.schedule_section), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + WaypointExpandableSection( + icon = Icons.Filled.Headphones, + title = stringResource(R.string.playback_rules_section), + summary = when (playbackMode) { + PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry) + PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once) + PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited) + }, + initiallyExpanded = maxPlayCountError, + highlight = maxPlayCountError ) { - Text( - text = stringResource(R.string.schedule_enabled_label), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = scheduleEnabled, - onCheckedChange = { scheduleEnabled = it } - ) - } - - if (scheduleEnabled) { - - // ---- Start date/time ---- - Text( - text = stringResource(R.string.schedule_start_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + var modeMenuExpanded by remember { mutableStateOf(false) } + val modeLabel = when (playbackMode) { + PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry) + PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once) + PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited) + } + ExposedDropdownMenuBox( + expanded = modeMenuExpanded, + onExpandedChange = { modeMenuExpanded = it } ) { - OutlinedButton( - onClick = { - showDateTimePicker(scheduleStartMillis) { millis -> - scheduleStartMillis = millis - scheduleRangeError = false - } + OutlinedTextField( + value = modeLabel, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.playback_mode_label)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded) }, - modifier = Modifier.weight(1f) + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = modeMenuExpanded, + onDismissRequest = { modeMenuExpanded = false } ) { - Icon( - Icons.Filled.CalendarMonth, - contentDescription = null, - modifier = Modifier.size(16.dp) + DropdownMenuItem( + text = { Text(stringResource(R.string.playback_mode_every_entry)) }, + onClick = { playbackMode = PlaybackMode.EVERY_ENTRY; modeMenuExpanded = false } ) - Spacer(Modifier.width(6.dp)) - Text( - text = if (scheduleStartMillis != null) - scheduleStartMillis.toDisplayDateString() - else - stringResource(R.string.schedule_pick_start), - maxLines = 1 + DropdownMenuItem( + text = { Text(stringResource(R.string.playback_mode_once)) }, + onClick = { playbackMode = PlaybackMode.ONCE; modeMenuExpanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.playback_mode_limited)) }, + onClick = { playbackMode = PlaybackMode.LIMITED_COUNT; modeMenuExpanded = false } ) - } - if (scheduleStartMillis != null) { - TextButton(onClick = { - scheduleStartMillis = null - scheduleRangeError = false - }) { - Text(stringResource(R.string.schedule_clear_start)) - } } } - // ---- End date/time ---- - Text( - text = stringResource(R.string.schedule_end_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedButton( - onClick = { - showDateTimePicker(scheduleEndMillis) { millis -> - scheduleEndMillis = millis - scheduleRangeError = false - } - }, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.Filled.CalendarMonth, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - text = if (scheduleEndMillis != null) - scheduleEndMillis.toDisplayDateString() - else - stringResource(R.string.schedule_pick_end), - maxLines = 1 - ) - } - if (scheduleEndMillis != null) { - TextButton(onClick = { - scheduleEndMillis = null - scheduleRangeError = false - }) { - Text(stringResource(R.string.schedule_clear_end)) - } - } - } - - // Range validation error - if (scheduleRangeError) { - Text( - text = stringResource(R.string.schedule_end_before_start_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + if (playbackMode == PlaybackMode.LIMITED_COUNT) { + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = maxPlayCountStr, + onValueChange = { maxPlayCountStr = it; maxPlayCountError = false }, + label = { Text(stringResource(R.string.playback_max_count_label)) }, + placeholder = { Text(stringResource(R.string.playback_max_count_hint)) }, + isError = maxPlayCountError, + supportingText = if (maxPlayCountError) { + { Text(stringResource(R.string.playback_max_count_error), color = MaterialTheme.colorScheme.error) } + } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() ) } - // ---- Daily time window ---- - Text( - text = stringResource(R.string.schedule_daily_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - // Daily start time - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedButton( - onClick = { - showTimePicker(allowedStartMinutes) { minutes -> - allowedStartMinutes = minutes - } - }, - modifier = Modifier.weight(1f) + if (!isNew) { + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() ) { - Icon( - Icons.Filled.Schedule, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) Text( - text = if (allowedStartMinutes != null) - allowedStartMinutes.toDisplayTimeString() - else - stringResource(R.string.schedule_pick_daily_start), - maxLines = 1 + text = stringResource(R.string.playback_count_info, currentPlayCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) - } - if (allowedStartMinutes != null) { - TextButton(onClick = { allowedStartMinutes = null }) { - Text(stringResource(R.string.schedule_clear_window)) + TextButton(onClick = { currentPlayCount = 0 }) { + Text( + text = stringResource(R.string.playback_count_reset), + style = MaterialTheme.typography.bodySmall + ) } } } - - // Daily end time - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - OutlinedButton( - onClick = { - showTimePicker(allowedEndMinutes) { minutes -> - allowedEndMinutes = minutes - } - }, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.Filled.Schedule, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - text = if (allowedEndMinutes != null) - allowedEndMinutes.toDisplayTimeString() - else - stringResource(R.string.schedule_pick_daily_end), - maxLines = 1 - ) - } - if (allowedEndMinutes != null) { - TextButton(onClick = { allowedEndMinutes = null }) { - Text(stringResource(R.string.schedule_clear_window)) - } - } - } - - // Info: daily window can cross midnight - Text( - text = stringResource(R.string.schedule_daily_midnight_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) - ) } - // v2.5.16 — Kopiersektion wurde in den oberen Tour-Bereich - // verschoben und ist dort gut sichtbar. + // --------------------------------------------------------------- + // ABSCHNITT 6: Zeitplan (einklappbar) + // --------------------------------------------------------------- + WaypointExpandableSection( + icon = Icons.Filled.Schedule, + title = stringResource(R.string.schedule_section), + summary = if (scheduleEnabled) "Aktiv" else "Aus", + initiallyExpanded = scheduleRangeError, + highlight = scheduleRangeError + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.schedule_enabled_label), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = scheduleEnabled, + onCheckedChange = { scheduleEnabled = it } + ) + } + + if (scheduleEnabled) { + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.schedule_start_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + showDateTimePicker(scheduleStartMillis) { millis -> + scheduleStartMillis = millis + scheduleRangeError = false + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.CalendarMonth, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = if (scheduleStartMillis != null) scheduleStartMillis.toDisplayDateString() + else stringResource(R.string.schedule_pick_start), + maxLines = 1 + ) + } + if (scheduleStartMillis != null) { + TextButton(onClick = { scheduleStartMillis = null; scheduleRangeError = false }) { + Text(stringResource(R.string.schedule_clear_start)) + } + } + } + + Text( + text = stringResource(R.string.schedule_end_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + showDateTimePicker(scheduleEndMillis) { millis -> + scheduleEndMillis = millis + scheduleRangeError = false + } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.CalendarMonth, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = if (scheduleEndMillis != null) scheduleEndMillis.toDisplayDateString() + else stringResource(R.string.schedule_pick_end), + maxLines = 1 + ) + } + if (scheduleEndMillis != null) { + TextButton(onClick = { scheduleEndMillis = null; scheduleRangeError = false }) { + Text(stringResource(R.string.schedule_clear_end)) + } + } + } + + if (scheduleRangeError) { + Text( + text = stringResource(R.string.schedule_end_before_start_error), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + + Text( + text = stringResource(R.string.schedule_daily_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + showTimePicker(allowedStartMinutes) { minutes -> allowedStartMinutes = minutes } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = if (allowedStartMinutes != null) allowedStartMinutes.toDisplayTimeString() + else stringResource(R.string.schedule_pick_daily_start), + maxLines = 1 + ) + } + if (allowedStartMinutes != null) { + TextButton(onClick = { allowedStartMinutes = null }) { + Text(stringResource(R.string.schedule_clear_window)) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + showTimePicker(allowedEndMinutes) { minutes -> allowedEndMinutes = minutes } + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = if (allowedEndMinutes != null) allowedEndMinutes.toDisplayTimeString() + else stringResource(R.string.schedule_pick_daily_end), + maxLines = 1 + ) + } + if (allowedEndMinutes != null) { + TextButton(onClick = { allowedEndMinutes = null }) { + Text(stringResource(R.string.schedule_clear_window)) + } + } + } + + Text( + text = stringResource(R.string.schedule_daily_midnight_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // --------------------------------------------------------------- + // ABSCHNITT 7: Kopieren & duplizieren (einklappbar, ganz unten) + // v2.5.16 — Kopiersektion (nur im Bearbeiten-Modus) + // --------------------------------------------------------------- + if (waypoint != null && onCopyToOtherTour != null) { + WaypointExpandableSection( + icon = Icons.Filled.ContentCopy, + title = "Kopieren & duplizieren", + summary = "In andere Tour oder hier duplizieren", + initiallyExpanded = false + ) { + WaypointCopySectionBody( + currentTour = tourName, + existingTours = existingTours, + onCopy = { target -> + onCopyToOtherTour(target) + onDismiss() + } + ) + } + } } }, confirmButton = { @@ -993,10 +1035,8 @@ fun WaypointEditDialog( lngError = lng == null || lng !in -180.0..180.0 radiusError = radius == null || radius <= 0f - // --- Tour name validation: fallback to default if blank --- val safeTourName = tourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME } - // --- Playback rule validation --- var maxCount: Int? = null if (playbackMode == PlaybackMode.LIMITED_COUNT) { maxCount = maxPlayCountStr.trim().toIntOrNull() @@ -1005,7 +1045,6 @@ fun WaypointEditDialog( maxPlayCountError = false } - // --- Schedule validation --- scheduleRangeError = if (scheduleEnabled) { scheduleStartMillis != null && scheduleEndMillis != null && @@ -1016,11 +1055,6 @@ fun WaypointEditDialog( maxPlayCountError || scheduleRangeError if (!hasError) { - // v2.5.12 — Clips über withClips() konsistent in audioClips + - // Legacy-soundUri/-soundName spiegeln. Filtere Clips ohne URI - // (z. B. wenn der User auf "+" geklickt, aber dann den Picker - // abgebrochen hat — solche Phantom-Clips entstehen hier zwar - // nicht, aber wir bleiben defensiv). val cleanClips = clips.filter { it.hasAudio } val base = (waypoint ?: Waypoint()).copy( name = name.trim(), @@ -1028,7 +1062,6 @@ fun WaypointEditDialog( longitude = lng!!, radiusMeters = radius!!, tourName = safeTourName, - // Playback rules playbackMode = playbackMode, maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null, playCount = currentPlayCount, @@ -1039,8 +1072,6 @@ fun WaypointEditDialog( allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null ) val withAudio = if (cleanClips.isEmpty()) { - // Keine Clips mehr — Legacy-Felder leeren, damit kein - // Geister-Audio aus alten Daten zurückbleibt. base.copy(audioClips = emptyList(), soundUri = "", soundName = "") } else { base.withClips(cleanClips) @@ -1059,6 +1090,218 @@ fun WaypointEditDialog( ) } +// --------------------------------------------------------------- +// Hilfsfunktionen für Card-Layout v2.6.1 +// --------------------------------------------------------------- + +/** + * Einheitliche Abschnittskarte für den WaypointEditDialog. + * Gibt dem Dialog eine strukturierte, ruhigere Optik mit + * klarer visueller Trennung der Inhalte. + */ +@Composable +private fun WaypointSectionCard( + content: @Composable ColumnScope.() -> Unit +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + content = content + ) + } +} + +/** + * Abschnitts-Label mit Icon und Text, Teal-Akzentfarbe (primary). + */ +@Composable +private fun WaypointSectionLabel( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } +} + +// --------------------------------------------------------------- +// v2.7.34 — Ruhigeres Layout: Kopfkarte + einklappbare Abschnitte +// --------------------------------------------------------------- + +/** Kurzformel für die Positionsanzeige (Kopfkarte / Abschnittszusammenfassung). */ +private fun positionSummary(latStr: String, lngStr: String): String { + val lat = latStr.trim().replace(',', '.').toDoubleOrNull() + val lng = lngStr.trim().replace(',', '.').toDoubleOrNull() + return if (lat != null && lng != null) { + "%.5f · %.5f".format(Locale.US, lat, lng) + } else { + "Noch keine Position" + } +} + +/** Kurzformel für die Audioanzeige (Kopfkarte / Abschnittszusammenfassung). */ +private fun audioSummary(clipCount: Int): String = when (clipCount) { + 0 -> "Kein Audio" + 1 -> "1 Audio-Clip" + else -> "$clipCount Audio-Clips" +} + +/** + * Kompakte Status-/Kopfkarte oben im Dialog. Zeigt den Wegpunkt-Namen (oder + * einen Platzhalter), die aktuelle Tour und einen kurzen Überblick über + * Position und Audio. Reine Anzeige – keine Eingaben, keine Logik. + */ +@Composable +private fun WaypointHeaderCard( + name: String, + tourName: String, + positionSummary: String, + audioSummary: String +) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(28.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name.ifBlank { "Neuer Wegpunkt" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Tour: $tourName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "$positionSummary · $audioSummary", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +/** + * Einklappbarer Abschnitt im Stil der [WaypointSectionCard]. Der Kopf zeigt + * Icon, Titel und – wenn eingeklappt – eine kurze Zusammenfassung; ein Tippen + * auf den Kopf klappt den Inhalt auf/zu. So bleibt der Dialog ruhig, ohne + * Funktionen zu verstecken. + * + * @param initiallyExpanded Startzustand (z. B. true bei Validierungsfehlern). + * @param highlight Hebt den Kopf in der Fehlerfarbe hervor. + */ +@Composable +private fun WaypointExpandableSection( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + summary: String, + initiallyExpanded: Boolean = false, + highlight: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) { + var expanded by remember { mutableStateOf(initiallyExpanded) } + val accent = if (highlight) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .padding(horizontal = 12.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = accent + ) + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = accent + ) + if (!expanded && summary.isNotBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Icon( + if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = if (expanded) "Einklappen" else "Aufklappen", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (expanded) { + Spacer(Modifier.height(8.dp)) + content() + } + } + } +} + /** * v2.5.16 — Kopiersektion für den Bearbeiten-Dialog. * @@ -1074,7 +1317,7 @@ fun WaypointEditDialog( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun WaypointCopySection( +private fun WaypointCopySectionBody( currentTour: String, existingTours: List, onCopy: (targetTour: String) -> Unit, @@ -1084,81 +1327,74 @@ private fun WaypointCopySection( existingTours.filter { it.isNotBlank() && it != effectiveCurrent } } - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - Text( - text = "Wegpunkt kopieren", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "Erzeugt eine eigenständige Kopie mit allen Clips. Das Original " + - "bleibt unverändert. Die Kopie wird ans Ende der Zieltour gehängt.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) - ) + Column { + Text( + text = "Erzeugt eine eigenständige Kopie mit allen Clips. Das Original " + + "bleibt unverändert. Die Kopie wird ans Ende der Zieltour gehängt.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) - if (otherTours.isNotEmpty()) { - var copyTargetMenuExpanded by remember { mutableStateOf(false) } - var copyTarget by remember(otherTours) { mutableStateOf(otherTours.first()) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ExposedDropdownMenuBox( - expanded = copyTargetMenuExpanded, - onExpandedChange = { copyTargetMenuExpanded = it }, - modifier = Modifier.weight(1f) + if (otherTours.isNotEmpty()) { + var copyTargetMenuExpanded by remember { mutableStateOf(false) } + var copyTarget by remember(otherTours) { mutableStateOf(otherTours.first()) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - OutlinedTextField( - value = copyTarget, - onValueChange = {}, - readOnly = true, - label = { Text("Ziel-Tour") }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = copyTargetMenuExpanded - ) - }, - singleLine = true, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( + ExposedDropdownMenuBox( expanded = copyTargetMenuExpanded, - onDismissRequest = { copyTargetMenuExpanded = false } + onExpandedChange = { copyTargetMenuExpanded = it }, + modifier = Modifier.weight(1f) ) { - otherTours.forEach { t -> - DropdownMenuItem( - text = { Text(t) }, - onClick = { - copyTarget = t - copyTargetMenuExpanded = false - } - ) + OutlinedTextField( + value = copyTarget, + onValueChange = {}, + readOnly = true, + label = { Text("Ziel-Tour") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = copyTargetMenuExpanded) + }, + singleLine = true, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = copyTargetMenuExpanded, + onDismissRequest = { copyTargetMenuExpanded = false } + ) { + otherTours.forEach { t -> + DropdownMenuItem( + text = { Text(t) }, + onClick = { + copyTarget = t + copyTargetMenuExpanded = false + } + ) + } } } + FilledTonalButton(onClick = { onCopy(copyTarget) }) { + Text("Kopieren") + } } - FilledTonalButton(onClick = { onCopy(copyTarget) }) { - Text("Kopieren") - } + } else { + Text( + text = "Es ist noch keine weitere Tour vorhanden. Du kannst diesen " + + "Wegpunkt aber in der aktuellen Tour '" + effectiveCurrent + "' duplizieren.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - } else { - Text( - text = "Es ist noch keine weitere Tour vorhanden. Du kannst diesen " + - "Wegpunkt aber in der aktuellen Tour „$effectiveCurrent“ duplizieren.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) - ) - } - // Zweite Aktion: Duplizieren in derselben Tour. Immer verfügbar, auch - // wenn keine andere Tour existiert — deckt den Single-Tour-Fall ab. - OutlinedButton( - onClick = { onCopy(effectiveCurrent) }, - modifier = Modifier.fillMaxWidth() - ) { - Text("In dieser Tour duplizieren („$effectiveCurrent“)") + Spacer(Modifier.height(6.dp)) + OutlinedButton( + onClick = { onCopy(effectiveCurrent) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("In dieser Tour duplizieren (" + effectiveCurrent + ")") + } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt index 0bcf6cc..56f60fd 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt @@ -90,7 +90,9 @@ fun WaypointListScreen( ) { val context = LocalContext.current val waypoints by viewModel.waypoints.collectAsState() - val tourList by viewModel.tourList.collectAsState() + // v2.7.5 — visibleTourList blendet Standard-Tour aus der UI aus (Tabs, Dialoge), + // sobald echte Touren vorhanden sind. Intern bleibt sie als Fallback erhalten. + val tourList by viewModel.visibleTourList.collectAsState() val selectedTour by viewModel.selectedTour.collectAsState() val serviceRunning by viewModel.serviceRunning.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState() @@ -160,6 +162,33 @@ fun WaypointListScreen( // statt als Bottom-Sheet angezeigt. Minimize stellt den Sheet-Modus // wieder her, ohne den Player-Zustand zu verlieren. var clipDetailFullscreen by remember { mutableStateOf(false) } + + // v2.7.32 — Player-Sichtbarkeits-Regression behoben. + // Wird ein Track/Clip manuell gestartet (Karten-Play-Button oder + // ManualPlayerBar), soll der etablierte GPS2Audio-Player (ClipDetailSheet / + // -Fullscreen) wieder automatisch erscheinen. Zuvor öffnete sich die + // Detailansicht nur durch Tippen auf die Karte; bei manuellem Start blieb + // der Player unsichtbar, obwohl Audio lief. + // Der Effekt folgt dem aktiv abgespielten Wegpunkt und öffnet die Ansicht, + // sobald Wiedergabe beginnt. Die bestehende Auto-Follow-Logik (Clip-Wechsel + // innerhalb geöffneter Ansicht) und manuelles Schließen bleiben unberührt: + // Schließt der Nutzer die Ansicht, wird sie erst beim nächsten Start eines + // anderen Wegpunkts wieder geöffnet. + val activePlaybackWaypointId: String? = when { + singlePlayingWaypointId != null -> singlePlayingWaypointId + manualPlaying -> currentPlayable?.waypointId + else -> null + } + LaunchedEffect(activePlaybackWaypointId) { + val activeId = activePlaybackWaypointId + if (activeId != null && + clipDetailWaypointId != activeId && + filteredWaypoints.any { it.id == activeId } + ) { + clipDetailWaypointId = activeId + } + } + var showOverflowMenu by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) } var showWakeLockDialog by remember { mutableStateOf(false) } @@ -459,22 +488,65 @@ fun WaypointListScreen( showNewTourDialog = true } ) + // v2.7.5 — Umbenennen/Duplizieren für Standard-Tour (Blueprint) sperren + val isSystemTour = selectedTour == Waypoint.DEFAULT_TOUR_NAME DropdownMenuItem( - text = { Text(stringResource(R.string.tour_rename)) }, - leadingIcon = { Icon(Icons.Filled.Edit, null) }, + text = { + Text( + stringResource(R.string.tour_rename), + color = if (isSystemTour) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else + LocalContentColor.current + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Edit, + null, + tint = if (isSystemTour) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else + LocalContentColor.current + ) + }, onClick = { - showOverflowMenu = false - showRenameTourDialog = true - } + if (!isSystemTour) { + showOverflowMenu = false + showRenameTourDialog = true + } + }, + enabled = !isSystemTour ) // v2.5.15 — Tour als Variante duplizieren + // v2.7.5 — Duplizieren der Standard-Tour gesperrt DropdownMenuItem( - text = { Text("Tour duplizieren…") }, - leadingIcon = { Icon(Icons.Filled.ContentCopy, null) }, + text = { + Text( + "Tour duplizieren…", + color = if (isSystemTour) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else + LocalContentColor.current + ) + }, + leadingIcon = { + Icon( + Icons.Filled.ContentCopy, + null, + tint = if (isSystemTour) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else + LocalContentColor.current + ) + }, onClick = { - showOverflowMenu = false - showDuplicateTourDialog = true - } + if (!isSystemTour) { + showOverflowMenu = false + showDuplicateTourDialog = true + } + }, + enabled = !isSystemTour ) if (selectedTour == Waypoint.DEFAULT_TOUR_NAME) { // Standard-Tour kann nicht gelöscht werden – Eintrag deaktiviert diff --git a/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt index 896a9b5..1a94864 100644 --- a/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt +++ b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt @@ -37,6 +37,7 @@ import java.util.Locale import java.util.UUID import de.waypointaudio.io.BackupImportManager import de.waypointaudio.io.ImportExportManager +import de.waypointaudio.ui.RoutePlan import de.waypointaudio.repository.WaypointRepository import de.waypointaudio.service.BackgroundMusicManager import de.waypointaudio.service.LivePttManager @@ -46,10 +47,13 @@ import de.waypointaudio.service.TrackRecordingManager import de.waypointaudio.service.WaypointLocationService import de.waypointaudio.service.MusicPlaybackState import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withTimeout @@ -121,12 +125,42 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application private val _tourList = MutableStateFlow>(listOf(Waypoint.DEFAULT_TOUR_NAME)) val tourList: StateFlow> = _tourList.asStateFlow() + /** + * v2.7.5 — Öffentlich sichtbare Tourliste für die UI (Audio-Bibliothek, Tabs). + * Die Standard-Tour (DEFAULT_TOUR_NAME) wird ausgeblendet, sobald mindestens + * eine echte (benutzerdefinierte) Tour existiert. Intern bleibt DEFAULT_TOUR_NAME + * stets als Fallback erhalten — Daten werden nicht gelöscht. + * Wenn keine echten Touren vorhanden sind, wird DEFAULT_TOUR_NAME als + * einziger Eintrag angezeigt (leerer Zustand für neue Nutzer). + */ + val visibleTourList: StateFlow> = _tourList + .map { list -> + val realTours = list.filter { it != Waypoint.DEFAULT_TOUR_NAME } + if (realTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else realTours + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = listOf(Waypoint.DEFAULT_TOUR_NAME) + ) + /** * Set of tour names that have been deleted optimistically. * The combine collector must not re-add these from waypoint-derived tours. */ private val _deletedTours = mutableSetOf() + /** + * v2.7.14 — Neutral-Explorer-Modus-Flag. + * Wenn true, unterdrückt der combine-Kollektor den automatischen Upgrade + * von DEFAULT_TOUR_NAME zur ersten echten Tour. Das ermöglicht einen + * bewusst leeren Explorer-Einstieg aus der Übersicht/Audio-Bibliothek. + * Wird durch selectNeutralTourForNewExplorer() aktiviert und durch + * selectTour(echte Tour) oder upsertFromNeutralExplorer() wieder deaktiviert. + */ + @Volatile + private var _neutralExplorerMode: Boolean = false + /** * Aktuell ausgewählte Tour (für Tabs und Filter). */ @@ -163,6 +197,19 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application private val _manualPlaying = MutableStateFlow(false) val manualPlaying: StateFlow = _manualPlaying.asStateFlow() + // v2.7.37 — Kurze Stille zwischen Clips/Wegpunkten bei der manuellen + // Sequenz-Wiedergabe ("Hörbuch-Gefühl"). Nur im Auto-Advance-Pfad der + // Tour-/Sequenz-Wiedergabe; NICHT bei GPS, PTT, Atmo oder Einzelclip-Vorschau. + // Bewusst kein UI-Setting und keine Persistenz — fester interner Wert. + private var manualGapJob: Job? = null + + /** Bricht eine laufende Auto-Advance-Pause ab, sodass kein verzögerter + * Start nach Stop/Pause/Skip/Tour-Wechsel mehr feuert. */ + private fun cancelManualGap() { + manualGapJob?.cancel() + manualGapJob = null + } + /** Name of the waypoint currently loaded in the manual player (null = nothing loaded). */ private val _manualCurrentName = MutableStateFlow(null) val manualCurrentName: StateFlow = _manualCurrentName.asStateFlow() @@ -265,6 +312,66 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application get() = _activeTourRoute private val _activeTourRoute = MutableStateFlow(null) + // ─── Session-Route (v2.6.2) ─────────────────────────────────────────────── + // Hält die zuletzt im Explorer berechnete/geplante Route (OSRM oder Planlinie) + // für die aktuelle Tour. Überlebt Navigation/Workspace-Wechsel, da im ViewModel + // gespeichert. Wird beim Tourwechsel oder explizitem Löschen geleert. + // Kein Datenbankschreiben — dient ausschließlich der Session-Wiederherstellung. + // Enthält: tourName (zur Validierung) + TourRoute-Objekt. + private val _currentSessionRoute = + MutableStateFlow?>(null) + val currentSessionRoute: StateFlow?> = + _currentSessionRoute.asStateFlow() + + /** + * v2.6.2 — Speichert die aktuell berechnete/geplante Route für die laufende + * Explorer-Session. Wird nicht in die Datenbank geschrieben, sondern dient + * der Wiederherstellung der Kartenvisualisierung nach einem Workspace-Wechsel. + * Zusätzlich wird die Route dauerhaft als Tourroute persistiert, damit sie + * auch nach App-Neustart sichtbar ist. + */ + fun setCurrentSessionRoute( + tourName: String, + route: de.waypointaudio.data.TourRoute + ) { + _currentSessionRoute.value = tourName to route + // Gleichzeitig dauerhaft persistieren, damit activeTourRoute beim Zurückkehren + // die Route enthält und savedRouteLine sie als Backup zeichnet. + saveTourRouteFor(tourName, route) + } + + /** + * v2.6.2 — Löscht die Session-Route (z. B. beim Tourwechsel oder explizitem + * Löschen durch den Nutzer). Berührt nicht die persistente Tourroute. + */ + fun clearCurrentSessionRoute() { + _currentSessionRoute.value = null + } + + // ─── Session-Plan (v2.7.2) ─────────────────────────────────────────────── + // Hält den zuletzt genutzten RoutePlan (Start/Via/Ziel) pro Tour. + // Überlebt Navigation/Workspace-Wechsel, da im ViewModel gespeichert. + // Wird beim echten Tourwechsel geleert. Kein DB-Schreiben. + private val _currentSessionPlan = + MutableStateFlow?>(null) + val currentSessionPlan: StateFlow?> = + _currentSessionPlan.asStateFlow() + + /** + * v2.7.2 — Speichert den aktuellen RoutePlan für die laufende Session. + * Wird bei jedem Plan-Update aufgerufen (Start/Via/Ziel-Änderung). + */ + fun setCurrentSessionPlan(tourName: String, plan: RoutePlan) { + _currentSessionPlan.value = tourName to plan + } + + /** + * v2.7.2 — Löscht den Session-Plan (beim Tourwechsel oder explizitem Leeren). + */ + fun clearCurrentSessionPlan() { + _currentSessionPlan.value = null + } + // ─── Tour-Cover (v2.5.5) ────────────────────────────────────────────────── // Persistente Map tourName → TourCover. Wenn keine Auswahl existiert, // behält die UI ihre deterministische Standard-Optik (siehe TourHeroSection). @@ -301,6 +408,16 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application if (current.isNotBlank() && current !in combined) { _selectedTour.value = combined.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME } + // v2.7.5 — Wenn Standard-Tour ausgewählt ist, aber echte Touren existieren, + // automatisch zur ersten echten Tour wechseln. Standard bleibt interner Fallback. + // v2.7.14 — Wenn _neutralExplorerMode aktiv ist, wird dieser Upgrade NICHT + // durchgeführt: Explorer soll leer/neutral bleiben (Einstieg aus Übersicht). + if (_selectedTour.value == Waypoint.DEFAULT_TOUR_NAME && !_neutralExplorerMode) { + val firstReal = combined.firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME } + if (firstReal != null) { + _selectedTour.value = firstReal + } + } } } // Begleitmusik-Einstellungen für die initiale Tour laden @@ -329,10 +446,15 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application _activeTourRoute.value = map[_selectedTour.value] } } - // Beim Tourwechsel die aktive Tourroute aktualisieren. + // Beim Tourwechsel die aktive Tourroute und Session-Route aktualisieren. viewModelScope.launch { _selectedTour.collect { tour -> _activeTourRoute.value = _tourRoutes.value[tour] + // v2.6.2 — Session-Route leeren, wenn der Nutzer die Tour wechselt. + // So zeigt der Explorer nach einem Tourwechsel keine fremde Route. + _currentSessionRoute.value = null + // v2.7.2 — Session-Plan leeren beim Tourwechsel. + _currentSessionPlan.value = null } } // v2.5.5 — Tour-Cover beobachten. @@ -415,13 +537,59 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application /** * v2.5.34 — Explorer-Kontext-Fix: - * Setzt die ausgewählte Tour auf den neutralen Standard zurück. - * Wird aufgerufen, wenn der Explorer aus der Audio-Bibliothek/Home - * geöffnet wird, damit kein zuletzt aktiver Tour-Kontext übernommen wird. + * Setzt die ausgewählte Tour auf den neutralen Standard zurück, + * AUSSER wenn bereits echte (nicht-Standard) Touren existieren – + * dann wird direkt die erste echte Tour gewählt. + * + * v2.7.10 — Waypoint-Persistence-Fix: + * Wenn eine spontane "Unbenannte Tour" im Explorer erzeugt wurde und der + * Nutzer den Explorer verlässt und erneut öffnet, darf selectDefaultTour() + * nicht blind auf DEFAULT zurückspringen und die frisch erstellten Wegpunkte + * hinter einem Tour-Filter verstecken. Stattdessen: Wenn echte Touren + * vorhanden sind, sofort zur ersten echten Tour wechseln (gleiche Logik wie + * im combine-Kollektor, aber synchron, um Race Conditions zu vermeiden). + * * Der GPS-Dienst und die Wiedergabe werden dabei NICHT unterbrochen. */ fun selectDefaultTour() { - selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) + val knownTours = _tourList.value + val firstReal = knownTours.firstOrNull { it != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME } + if (firstReal != null) { + // Echte Touren existieren – direkt zur ersten echten Tour wechseln. + // Kein Rücksprung auf DEFAULT_TOUR_NAME, damit Wegpunkte sichtbar bleiben. + selectTour(firstReal) + } else { + // Keine echten Touren – Standard-Tour ist der einzig sinnvolle Zustand. + selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) + } + } + + /** + * v2.7.14 — Neutraler Explorer-Start aus der Übersicht/Audio-Bibliothek. + * + * Setzt selectedTour explizit auf DEFAULT_TOUR_NAME ("Unbenannte Tour") und + * aktiviert den Neutral-Explorer-Modus-Flag, der den automatischen Upgrade + * durch den combine-Kollektor (v2.7.5-Logik) verhindert. + * + * Konzept: + * - Explorer aus Audio-Bibliothek/Übersicht → diese Funktion → leerer Kontext + * - Explorer aus Touransicht/WaypointListScreen → selectTour(tourName) bleibt unverändert + * + * Der Flag wird zurückgesetzt, sobald: + * a) der Nutzer einen Wegpunkt im neutralen Kontext setzt (upsertFromNeutralExplorer) + * b) der Nutzer explizit eine echte Tour über selectTour() auswählt + */ + fun selectNeutralTourForNewExplorer() { + _neutralExplorerMode = true + // Direkt auf DEFAULT setzen; selectTour() würde den Guard auslösen (DEFAULT nicht in + // knownTours wenn nur echte Touren existieren), daher direktes Assignment. + _selectedTour.value = de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + musicManager.loadTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) + viewModelScope.launch { + loadMusicSettingsForCurrentTour() + loadTourDefaultsForCurrentTour() + loadPersistedTrackForCurrentTour() + } } fun selectTour(tourName: String) { @@ -429,6 +597,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application val knownTours = _tourList.value val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName else knownTours.first() + // v2.7.14 — Wechsel zu einer echten Tour deaktiviert den Neutral-Explorer-Modus. + if (safeTarget != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) { + _neutralExplorerMode = false + } + // v2.7.38 — Audio-Zuverlässigkeit: Ein evtl. anstehender Auto-Advance- + // Gap-Job gehört zur alten Tour. Beim Tour-Wechsel abbrechen, damit kein + // verspäteter Start in der falschen Tour feuert. Aktiv laufende + // Wiedergabe bleibt davon unberührt (Persistenz, s. u.). + if (safeTarget != _selectedTour.value) { + cancelManualGap() + } _selectedTour.value = safeTarget // v2.5.30 — Playback-Persistenz beim Tour-Wechsel: // Laufende Wiedergabe (manuell oder GPS-gesteuert) wird beim blossen @@ -467,7 +646,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application // Use direct assignment instead of selectTour() to avoid triggering // music-loading coroutine before the tour list is fully stable. _selectedTour.value = trimmed + // v2.7.14 — Neue echte Tour erstellt: Neutral-Explorer-Modus deaktivieren. + if (trimmed != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) { + _neutralExplorerMode = false + } // Reset manual player state + cancelManualGap() manualPlayer.stop() _manualPlaying.value = false _manualCurrentName.value = null @@ -493,20 +677,78 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application return true } + /** + * v2.7.7 — Spontane-Tour-Autocreate (v2.7.8: "Unbenannte Tour"): + * Erzeugt eine neue echte Tour mit eindeutigem Namen ("Unbenannte Tour", + * "Unbenannte Tour 2", usw.) und wählt sie sofort als selectedTour. + * Wird aufgerufen, wenn im neutralen Explorer-Kontext (selectedTour == + * DEFAULT_TOUR_NAME) der erste Wegpunkt gesetzt wird. + * Rückgabe: der Name der neu erzeugten Tour. + * Interner Funktionsname bleibt createSpontaneousTour() für minimales Risiko. + */ + fun createSpontaneousTour(): String { + val base = "Unbenannte Tour" + val existing = _tourList.value + var candidate = base + var counter = 2 + while (candidate in existing) { + candidate = "$base $counter" + counter++ + } + addTour(candidate) // setzt auch _selectedTour auf candidate + return candidate + } + + /** + * v2.7.7 — Upsert-Wrapper für den neutralen Explorer-Kontext. + * Wenn [waypoint].tourName == DEFAULT_TOUR_NAME (neutral), wird automatisch + * eine neue Spontane Tour erzeugt, [waypoint] auf diese Tour umgeschrieben + * und erst dann gespeichert. Danach ist selectedTour = neue Tour. + * In allen anderen Kontexten verhält sich diese Funktion identisch zu [upsert]. + */ + fun upsertFromNeutralExplorer(waypoint: Waypoint) { + val isNeutral = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == Waypoint.DEFAULT_TOUR_NAME + if (isNeutral) { + val newTourName = createSpontaneousTour() + val adjusted = waypoint.copy(tourName = newTourName) + upsert(adjusted) + } else { + upsert(waypoint) + } + } + /** * Verschiebt eine Tour in der Tab-Reihenfolge. * Index-Verschiebung: from → to. Ungültige Indizes werden ignoriert. * Persistiert die neue Reihenfolge sofort. */ fun moveTour(fromIndex: Int, toIndex: Int) { - val current = _tourList.value.toMutableList() - if (fromIndex !in current.indices || toIndex !in current.indices) return + // v2.7.32 — Bugfix: Die Reorder-UI (TourReorderDialog) arbeitet auf + // visibleTourList (Standard-Tour ausgeblendet), moveTour operierte aber + // direkt auf _tourList (inkl. DEFAULT_TOUR_NAME). Lag die Standard-Tour + // an Index 0, ergab sich ein Off-by-one, wodurch die erste und letzte + // SICHTBARE Tour faktisch nicht mehr verschoben werden konnten. + // Fix: Indizes beziehen sich auf die sichtbare Liste; die ausgeblendete + // Standard-Tour behält ihre relative Position in _tourList. + val full = _tourList.value + val visible = full.filter { it != Waypoint.DEFAULT_TOUR_NAME } + if (fromIndex !in visible.indices || toIndex !in visible.indices) return if (fromIndex == toIndex) return - val item = current.removeAt(fromIndex) - current.add(toIndex, item) - _tourList.value = current + + val reorderedVisible = visible.toMutableList() + val item = reorderedVisible.removeAt(fromIndex) + reorderedVisible.add(toIndex, item) + + // Merge zurück nach _tourList: ausgeblendete Standard-Tour(en) an ihrer + // ursprünglichen relativen Position erhalten, sichtbare Touren in neuer + // Reihenfolge einsetzen. + val iter = reorderedVisible.iterator() + val merged = full.map { name -> + if (name == Waypoint.DEFAULT_TOUR_NAME) name else iter.next() + } + _tourList.value = merged viewModelScope.launch { - runCatching { repository.saveTours(current) } + runCatching { repository.saveTours(merged) } .onFailure { e -> android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tour-Reihenfolge", e) } @@ -558,10 +800,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch { val updated = current.map { if (it == oldName) trimmed else it } repository.saveTours(updated) - val updatedWps = _waypoints.value.map { + // v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen. + val freshWps = runCatching { repository.waypoints.first() } + .getOrDefault(_waypoints.value) + val updatedWps = freshWps.map { if (it.tourName == oldName) it.copy(tourName = trimmed) else it } - repository.saveAll(updatedWps) + repository.saveAllForced(updatedWps) // v2.4.6 — Tourroute-Eintrag mitziehen, damit die Route nicht // verwaist unter dem alten Namen liegen bleibt. runCatching { repository.renameTourRouteKey(oldName, trimmed) } @@ -596,7 +841,14 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application } } - /** Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive). */ + /** + * Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive). + * + * v2.7.21 — Waypoint-Preservation-Invariante: + * Diese Funktion schreibt AUSSCHLIESSLICH in KEY_TOUR_ROUTES. + * Audio-Wegpunkte (KEY_WAYPOINTS) werden durch diesen Aufruf NIEMALS verändert. + * Der Routenplaner darf diese Funktion frei nutzen. + */ fun saveTourRouteFor(tourName: String, route: de.waypointaudio.data.TourRoute) { viewModelScope.launch { runCatching { @@ -726,6 +978,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application val nextTour = current.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME if (_selectedTour.value == tourName) { // Reset manual player state + // v2.7.38 — Auch den anstehenden Auto-Advance-Gap-Job abbrechen, + // sonst startet er 1,7 s nach dem Stop fälschlich erneut Audio + // gegen die gerade gelöschte Tour. + cancelManualGap() manualPlayer.stop() _manualPlaying.value = false _manualCurrentName.value = null @@ -739,10 +995,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch { runCatching { repository.saveTours(current) - val updatedWps = _waypoints.value.map { + // v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen statt + // _waypoints.value, um sicherzustellen, dass keine Wegpunkte verloren + // gehen, falls _waypoints.value noch nicht vollständig geladen ist. + val freshWps = runCatching { repository.waypoints.first() } + .getOrDefault(_waypoints.value) + val updatedWps = freshWps.map { if (it.tourName == tourName) it.copy(tourName = Waypoint.DEFAULT_TOUR_NAME) else it } - repository.saveAll(updatedWps) + // Forciert schreiben, da dies eine bewusste Mutations-Operation ist + // (Tour-Löschung mit Reassignment auf DEFAULT_TOUR_NAME). + repository.saveAllForced(updatedWps) // Clear GPS track for the deleted tour repository.clearGpsTrack(tourName) // v2.4.6 — Gespeicherte Tourroute beim Löschen entfernen @@ -885,7 +1148,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application else base.withClips(newClips) } if (newWps.isNotEmpty()) { - repository.saveAll(_waypoints.value + newWps) + // v2.7.21 — Race-Condition-Fix: Statt _waypoints.value (könnte noch + // leer sein, wenn der DataStore-Lade-Coroutine noch nicht feuerte) + // direkt aus dem DataStore lesen. Das garantiert die vollständige + // Wegpunktliste und verhindert versehentlichen Datenverlust. + val currentWps = runCatching { repository.waypoints.first() } + .getOrDefault(_waypoints.value) + repository.saveAll(currentWps + newWps) } // Tour-Cover, Tour-Route und Tour-Defaults referenziell mitkopieren. runCatching { @@ -1193,49 +1462,68 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application ) { viewModelScope.launch { val ctx = getApplication() - val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap()) - val tourDefaultsMap = runCatching { - repository.tourDefaults.first() - }.getOrDefault(emptyMap()) - val musicSettingsMap = runCatching { - musicStore.settingsFlow.first() - }.getOrDefault(emptyMap()) + // v2.7.36 FIX: Das gesamte Backup (Flow-Reads, ZIP-Aufbau und das + // chunk-weise Kopieren aller Mediendateien) lief bisher direkt im + // viewModelScope auf dem Main-Dispatcher. Bei Backups mit Medien + // blockierte das den UI-Thread für Sekunden → ANR ("App reagiert + // nicht / Absturz"), obwohl die Datei erfolgreich geschrieben wurde. + // Wie der Import-Pfad läuft die schwere IO jetzt auf Dispatchers.IO; + // die State-Updates für Erfolg/Fehler bleiben danach auf Main. + val result = runCatching { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap()) + val tourDefaultsMap = runCatching { + repository.tourDefaults.first() + }.getOrDefault(emptyMap()) + val musicSettingsMap = runCatching { + musicStore.settingsFlow.first() + }.getOrDefault(emptyMap()) - val pkgInfo = runCatching { - ctx.packageManager.getPackageInfo(ctx.packageName, 0) - }.getOrNull() - @Suppress("DEPRECATION") - val versionCode = pkgInfo?.let { - runCatching { it.longVersionCode.toInt() } - .getOrDefault(it.versionCode) - } ?: 0 + val pkgInfo = runCatching { + ctx.packageManager.getPackageInfo(ctx.packageName, 0) + }.getOrNull() + @Suppress("DEPRECATION") + val versionCode = pkgInfo?.let { + runCatching { it.longVersionCode.toInt() } + .getOrDefault(it.versionCode) + } ?: 0 - val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap()) - val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap()) - // v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks). - val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList()) + val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap()) + val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap()) + // v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks). + val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList()) + + val input = de.waypointaudio.io.BackupExportManager.BackupInput( + waypoints = _waypoints.value, + tours = _tourList.value, + tourDefaults = tourDefaultsMap, + gpsTracks = gpsTracks, + musicSettings = musicSettingsMap, + tourRoutes = tourRoutesMap, + tourCovers = tourCoversMap, + trackDrafts = trackDraftsSnapshot, + appVersionName = pkgInfo?.versionName ?: "", + appVersionCode = versionCode + ) + val selection = de.waypointaudio.io.BackupExportManager.BackupSelection( + toursAndWaypoints = toursAndWaypoints, + trackDrafts = trackDrafts, + mediaAndAtmo = mediaAndAtmo, + settings = settings + ) + de.waypointaudio.io.BackupExportManager.writeBackup( + ctx, uri, selection, input + ) + } + }.getOrElse { e -> + // Falls trotz interner Absicherung etwas Unerwartetes durchschlägt + // (z. B. SecurityException auf der Ziel-URI): nicht abstürzen, + // sondern als nicht-fataler Fehler melden. + de.waypointaudio.io.BackupExportManager.BackupResult( + errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}" + ) + } - val input = de.waypointaudio.io.BackupExportManager.BackupInput( - waypoints = _waypoints.value, - tours = _tourList.value, - tourDefaults = tourDefaultsMap, - gpsTracks = gpsTracks, - musicSettings = musicSettingsMap, - tourRoutes = tourRoutesMap, - tourCovers = tourCoversMap, - trackDrafts = trackDraftsSnapshot, - appVersionName = pkgInfo?.versionName ?: "", - appVersionCode = versionCode - ) - val selection = de.waypointaudio.io.BackupExportManager.BackupSelection( - toursAndWaypoints = toursAndWaypoints, - trackDrafts = trackDrafts, - mediaAndAtmo = mediaAndAtmo, - settings = settings - ) - val result = de.waypointaudio.io.BackupExportManager.writeBackup( - ctx, uri, selection, input - ) if (result.errorMessage != null) { _errorMessage.value = result.errorMessage } else { @@ -1352,7 +1640,9 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application // ---- Persistieren ---- runCatching { if (toursAndWaypoints) { - repository.saveAll(payload.mergedWaypoints) + // v2.7.21 — Backup-Restore ist ein forcierter Vollersatz; Preservation-Guard + // nicht anwenden (mergedWaypoints kann leer sein bei leeren Backups). + repository.saveAllForced(payload.mergedWaypoints) if (payload.mergedTours != existingTours) { repository.saveTours(payload.mergedTours) } @@ -1567,6 +1857,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application _singlePlayingActive.value = false } + // v2.7.37 — Während der Stille-Pause läuft kein MediaPlayer, der State + // ist aber "playing". Pause-Tippen soll dann die Auto-Advance-Pause + // abbrechen und nicht neu starten. + if (manualGapJob != null) { + cancelManualGap() + _manualPlaying.value = false + stopProgressTicker(keepSnapshot = true) + musicManager.afterWaypointAudio() + return + } + if (manualPlayer.isPlaying) { manualPlayer.pause() _manualPlaying.value = false @@ -1655,6 +1956,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application */ private fun startPlayableAt(sequence: List, seqIndex: Int) { if (sequence.isEmpty()) return + // v2.7.37 — Jede neue Playback-Entscheidung (Skip/Play/Resume) bricht + // eine evtl. laufende Auto-Advance-Stille ab, damit kein verspäteter + // Start den frisch gewählten Clip überschreibt. + cancelManualGap() val safeIdx = seqIndex.coerceIn(0, sequence.size - 1) val item = sequence[safeIdx] _manualIndex.value = item.waypointIndex - 1 @@ -1684,7 +1989,18 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application title = title, tour = _selectedTour.value ) - manualPlayer.play(ctx, item.uri) { + manualPlayer.play(ctx, item.uri, onError = { + // v2.7.38 — Wiedergabe konnte nicht starten (z. B. Samsung-SAF-/ + // Codec-Fehler). Zustand sauber zurücksetzen statt "spielt" ohne + // Ton anzuzeigen, und den Nutzer informieren. + cancelManualGap() + _manualPlaying.value = false + _manualCurrentName.value = null + _currentPlayable.value = null + stopProgressTicker(keepSnapshot = false) + musicManager.afterWaypointAudio() + _errorMessage.value = "Audio konnte nicht abgespielt werden. Bitte erneut versuchen." + }) { if (!autoAdvance) { _manualPlaying.value = false _manualCurrentName.value = null @@ -1712,9 +2028,57 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application return@play } val nextItem = nextSeq[nextIdx] - _currentPlayable.value = nextItem - _manualIndex.value = nextItem.waypointIndex - 1 - playFromCurrentPlayable(autoAdvance = true) + // v2.7.37 — Kurze Stille vor dem nächsten Eintrag (Hörbuch-Gefühl). + // Läuft als abbrechbarer Job: Pause/Stop/Skip/Tour-Wechsel rufen + // cancelManualGap() und verhindern so einen verspäteten Auto-Start. + // Die Sequenz wird NACH der Pause erneut ausgewertet, da sich der + // Zustand (PTT, gelöschte Wegpunkte, deaktivierte Clips) während der + // Wartezeit ändern kann. + // + // v2.7.38 — Audio-Zuverlässigkeit (Samsung-Regressionsfix): + // Die Gap ist STRIKT auf den Auto-Advance NACH einem natürlich + // beendeten Clip beschränkt. Sie darf den Folge-Clip nur dann + // starten, wenn währenddessen weder die Tour gewechselt wurde noch + // der Nutzer pausiert/gestoppt hat. Andernfalls wird der verzögerte + // Start verworfen, statt den Zustand fälschlich auf "spielt" zu + // lassen oder Audio in der falschen Tour zu starten. + val gapTour = _selectedTour.value + cancelManualGap() + manualGapJob = viewModelScope.launch { + delay(MANUAL_GAP_MS) + manualGapJob = null + // Tour gewechselt während der Stille → kein Auto-Advance. + // Laufende/zukünftige Wiedergabe der anderen Tour nicht stören; + // nur den eigenen verspäteten Start verwerfen. + if (_selectedTour.value != gapTour) { + return@launch + } + // Nutzer hat während der Stille pausiert/gestoppt + // (z. B. Pause-Tippen, Stop, neuer Single-Clip) → nicht starten. + if (!_manualPlaying.value || manualPlayer.isSingleMode) { + return@launch + } + if (pttManager.pttActive.value) { + _manualPlaying.value = false + stopProgressTicker(keepSnapshot = true) + return@launch + } + val seqAfterGap = playableSequence() + val resolvedIdx = seqAfterGap.indexOfFirst { it.compoundId == nextItem.compoundId } + if (resolvedIdx < 0) { + // Nächster Eintrag existiert nicht mehr → sauber stoppen. + _manualPlaying.value = false + _manualCurrentName.value = null + _currentPlayable.value = null + stopProgressTicker(keepSnapshot = false) + musicManager.afterWaypointAudio() + return@launch + } + val resolved = seqAfterGap[resolvedIdx] + _currentPlayable.value = resolved + _manualIndex.value = resolved.waypointIndex - 1 + playFromCurrentPlayable(autoAdvance = true) + } } startProgressTicker() } @@ -1737,6 +2101,7 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application /** Stop manual playback completely (both playlist and single mode). */ fun manualStop() { + cancelManualGap() manualPlayer.stop() _manualPlaying.value = false _singlePlayingWaypointId.value = null @@ -1816,6 +2181,11 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application val wpName = waypoint.name.ifBlank { "Wegpunkt" } val displayName = if (clipTitle.isBlank()) wpName else "$wpName · $clipTitle" + // v2.7.37 — Einzelclip-Vorschau übernimmt: eine evtl. laufende + // Auto-Advance-Stille der Tour-Sequenz abbrechen, sonst würde der + // verzögerte Start die Vorschau überschreiben. + cancelManualGap() + // Tour-Play beenden, falls aktiv. if (_manualPlaying.value && !manualPlayer.isSingleMode) { manualPlayer.stop() @@ -2362,5 +2732,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application * Akku spürbar gegenüber häufigeren Ticks. */ private const val PROGRESS_TICK_MS = 500L + + /** + * v2.7.37 — Feste Stille-Pause vor dem automatischen Start des nächsten + * Eintrags in der manuellen Sequenz-Wiedergabe (Clip→Clip und + * Wegpunkt→Wegpunkt). Gibt der Wiedergabe ein hörbuchartiges Gefühl. + */ + private const val MANUAL_GAP_MS = 1700L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d65e2c..46877cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,6 +251,10 @@ Viele Grüße Neu in 2.5.23: Lizenzimport-Regression-Fix. Erstimport nach Neuinstallation funktioniert wieder zuverlässig. Dateiauswahl nutzt wieder OpenDocument() (echter Datei-Browser) statt GetContent() (das auf manchen Geräten nur Medien anzeigt). Reimport derselben Datei nach „Lizenz entfernen“ funktioniert via importNonce-Mechanismus. Fehlermeldungen (aus v2.5.22) bleiben differenziert. Geräte-ID und alle anderen Funktionen aus v2.5.21/v2.5.22 bleiben unverändert. Neu in 2.5.21: Lizenz-Re-Import-Bugfix und Geräte-ID. Der Lizenzimport funktioniert jetzt zuverlässig auch nach Entfernen und erneutem Importieren einer Lizenzdatei. Außerdem zeigt der Lizenz-Bereich jetzt eine Geräte-ID an (SHA-256-Hash) — für spätere optionale gerätegebundene Lizenzen. In dieser Version ist keine harte Gerätebindung aktiv. Alle bisherigen Funktionen aus v2.5.20 bleiben unverändert. Neu in 2.5.19: Menü- und UX-Politur. Der Eintrag „Track Editor" wurde aus dem Hauptmenü entfernt – die Funktion ist über den Explorer weiterhin vollständig erreichbar. „Klassische Ansicht" heißt jetzt „Bearbeitungsmodus" (klarere Begriffswahl). „Touren ordnen" liegt jetzt im Menü der Audio-Bibliothek (Hauptseite) und nicht mehr in der einzelnen Touransicht. Tour-Cover (Bild, Icon, Palette) werden weiterhin als Teil des ZIP-Backups exportiert und beim Import wiederhergestellt. Alle bisherigen Funktionen – additiver GPX-Import, Lizenz/Pro/About, Multi-Clip, Sequenzplayer, Countdown, maximierbarer Player, Tour duplizieren, Waypoint kopieren, Explorer/MapLibre/Routing, PTT/Atmo, Hintergrundwiedergabe – bleiben unverändert. + + Neu in 2.7.16 (RoutePlanner Restore): Der direkte Routen-Planer (Start/Ziel/Via, Planlinie, Online-Routing) war in v2.7.15 faelschlicherweise entfernt worden. Er ist jetzt wiederhergestellt. Es gibt nun zwei klar benannte Einstiege: (1) "Route planen" (AltRoute-Icon, blau) = RoutePlannerSheet mit Start/Ziel/Via, Planlinie und optionalem Online-Routing via OSRM; (2) "Tourverlauf bearbeiten" (Stift-Icon) = TourverlaufEditorSheet fuer manuelles Zeichnen/Setzen von Verlaufspunkten. Beide sind als FAB auf der Karte und im Layer-Panel zugaenglich. Karten-Rendering, Waypoints, Planlinie, Explorer-Kontext-Isolation: keine Aenderung. + Neu in 2.7.15 (Tourverlauf-Menü-Merge): Die beiden Menüpunkte „Tourverlauf Anpassung“ (Routenplaner) und „Tourverlauf bearbeiten“ (Editor) sind zu einem einzigen Einstieg zusammengeführt. Es gibt jetzt nur noch einen FAB-Knopf „Tourverlauf“ im Explorer — der öffnet den Tourverlauf-Editor, der Zeichnen, Punkte setzen und (optional) Online-Routing in einem Sheet bietet. Das Layer-Panel zeigt ebenfalls nur noch den einheitlichen Tourverlauf-Einstieg. Alle internen Funktionen (Planlinie, Online-Routing via OSRM, Speichern/Löschen des Tourverlaufs) bleiben vollständig erhalten. Karten-Rendering, Waypoints, Planlinie, Explorer-Kontext-Isolation: keine Änderung. + Neu in 2.6.0 (Tourverlauf/Storyboard): Der bisherige Routenplaner heißt jetzt „Tourverlauf“ — klar als manueller Verlauf ohne Auto-Navi-Erwartung. Verlaufspunkte (stille Orientierungspunkte) und Audio-Wegpunkte werden in Labels und Erklärtexten klar unterschieden. Online-Routing (OSRM) bleibt erhalten, wird aber als optional/experimentell eingestuft — die Planlinie genügt für den Tourverlauf. Das Storyboard-Panel erklärt den Unterschied: Tourverlaufspunkte = Linie, Wegpunkte = Audio. Keine Änderung an Logik, Backup, Lizenz, radiusbasiertem Playback oder GPS-Aufzeichnung. Alle v2.5.48-Funktionen bleiben vollständig erhalten. Neu in 2.5.1: Design-Harmonisierung der sekundären Bereiche. Die Tour-/Waypoint-Ansicht, klassische Hauptansicht, PTT-Bereich, Atmo/Stream-Playlist, Track-Entwürfe, Backup/Import sowie About-Dialog nutzen jetzt die gleiche GPS2Audio-Farbwelt wie die neue Startseite: dunkle Surface-Karten, türkise Primäraktionen, magenta/destruktiv, grün/aktiv, weichere Cards und kompaktere Listen. Tabs, Top-App-Bars und Dialoge wirken ruhiger und moderner, ohne dass Funktionen entfallen: Explorer, MapLibre, Routenplaner, POI, PTT, Atmo/Stream-Playlist, Recorder, Backup/Import, About/PayPal, Drag-and-drop/Sortierung, TourRoute-Persistenz und Classic-Fallback bleiben unverändert erreichbar. Wegpunkte lassen sich weiterhin direkt per Drag-and-drop verschieben. Neu in 2.4.0: MapLibre Engine Cutover — der Explorer rendert jetzt aktiv mit MapLibre Native (org.maplibre.gl 11.x), nicht mehr mit osmdroid. Wegpunkte, Standort/Follow, Trigger-Radius/Audiozone, GPS-Track, Drafts, Storyboard, Routing/POI-MVP, Suche und Tap-Editiermodus sind 1:1 portiert. Echter Pitch/Tilt für den Perspektiv-Modus über MapLibre-Kamera. Clip-Längenlinien werden ab jetzt nur noch entlang einer bekannten Geometrie (aufgezeichneter Track/Draft oder berechnete OSRM-Route) gezeichnet — keine freien Projektionen mehr. Bestehende Funktionen (Backup, PTT, Atmo, Audio-Bibliothek, Touren ordnen, Drag-and-drop, About/PayPal) unverändert. Der osmdroid-Renderer bleibt im Quelltext als dokumentierter Fallback erhalten, ist aber nicht mehr sichtbar verdrahtet. @@ -310,17 +314,19 @@ Viele Grüße Info / Quelle Satellit nutzt standardmäßig ESRI World Imagery (kostenfrei für persönliche/nicht-kommerzielle Nutzung, Attribution „Tiles © Esri\"). Eigene Tile-URL ist möglich, z. B. self-hosted oder ein anderer Anbieter. Es werden keine kostenpflichtigen APIs erzwungen. Perspektive aktiv: geneigte Kameraansicht als Vorschau. Echtes 3D-Terrain (DEM) folgt mit dem MapLibre-Native-Renderer. - Routenplaner - Folgt — kein externer Routingdienst aktiv. Self-hosted OSRM/Valhalla/GraphHopper geplant. + Tourverlauf-Planer + Kein externer Routingdienst aktiv. Online-Routing optional via Self-hosted OSRM/Valhalla/GraphHopper. MapLibre Demotiles (frei) - Routenplaner folgt - GPS2Audio v2.3.0 enthält bewusst keine Routing-Engine. Ein vollwertiger Routenplaner würde entweder einen externen, kostenpflichtigen Dienst benötigen (vermieden) oder eine self-hosted Engine (z. B. OSRM, Valhalla, GraphHopper) bzw. einen Offline-Routing-Ansatz. Die nächste Version bindet eine der genannten Engines an – das vorhandene Storyboard bleibt Eingabe. + Tourverlauf-Planer + GPS2Audio verwendet keinen Navigations-Routenplaner. Der Tourverlauf wird manuell gesetzt — keine Online-Routing-Pflicht, keine Kosten. Optionales Online-Routing via Self-hosted OSRM/Valhalla/GraphHopper kann konfiguriert werden. Tiles © Esri Satellit: eigene Tile-Quelle Audio-Dauer: %1$s Explorer + + Unbenannte Tour Explorer öffnen Karte · Standort · Wegpunkte (read-only) Standort nicht verfügbar — Berechtigung oder GPS prüfen. @@ -347,22 +353,25 @@ Viele Grüße Tap-Modus abbrechen Karteneditor (Track / Drafts) - - Karte · Standort · Wegpunkte · GPS-Aufzeichnung · Drafts · Storyboard + + Karte · Standort · Wegpunkte · GPS-Aufzeichnung · Drafts · Tourverlauf Aufzeichnung läuft · %1$d Punkte Draft aktiv: %1$s · %2$d Punkte · %3$d markiert Aufgenommene Tracks (Drafts) Aufgenommene Tracks Wähle einen Draft, um seine Punkte direkt im Explorer zu sehen und zu bearbeiten. Detaillierte Bearbeitung im Vollbild-Editor weiterhin verfügbar. - Storyboard / Routenplanung - Storyboard / Routenplanung - Bring die Wegpunkte der aktuellen Tour in eine gewünschte Reihenfolge und speichere sie als neue (oder bestehende) Tour. Kein Straßen-Routing — eine konzeptionelle Tour-Zusammenstellung. + + Tourverlauf (Storyboard) + Tourverlauf + Ordne die Audio-Wegpunkte entlang der Route in die gewünschte Reihenfolge. Tourpunkte ohne Audio formen die Linie, Wegpunkte mit Audio lösen den Ton aus. Zieltour (Name) - Keine Wegpunkte in der aktuellen Tour. Lege erst welche im Explorer an. + Keine Wegpunkte in der aktuellen Tour. Lege erst Wegpunkte im Explorer an. Nach oben Nach unten Reihenfolge übernehmen - Hinweis: Es wird keine externe Routing-Engine verwendet (keine API, keine Kosten). Das Storyboard ist eine geordnete Wegpunkt-Liste — Entwurfsstatus. + Tourpunkte ohne Audio formen die Routenlinie. Wegpunkte mit Audio lösen den Ton aus. Audio kann jederzeit nachträglich hinzugefügt werden. + + Route zuerst: Tourpunkte formen die Linie. Audio wird bewusst an einzelne Punkte angehängt. Trackpunkt %1$d Aktiviere zuerst einen Draft im Drafts-Sheet, um Trackpunkte zu bearbeiten. Tippe die neue Position an — der gewählte Trackpunkt wird dorthin verschoben. @@ -454,6 +463,25 @@ Viele Grüße Tour: %1$s Begleitmusik aktivieren + + Musik zur Tour + Aktiv für diese Tour + Aus – tippe zum Aktivieren + %1$d Titel in der Playlist + 1 Titel in der Playlist + %1$d Sender / Streams + 1 Sender / Stream + Noch keine Musik ausgewählt + Musik auswählen + Diese Tour hat noch keine Musik + Lege Titel aus deiner Bibliothek oder vom Gerät an – oder füge einen Radio-Stream hinzu. Die Musik begleitet die Tour und tritt bei Ansagen automatisch in den Hintergrund. + Quelle + Bibliothek + Dateien + Stream + Verhalten & Optionen + Bei Ansagen + Musikquelle Lokale Playlist @@ -509,6 +537,12 @@ Viele Grüße Normal unterlegen Musik läuft auf normaler Lautstärke parallel zum Wegpunkt-Ton. + + Bei Ansagen pausieren + Sanft aus- und einblenden + Leiser im Hintergrund + Weiterlaufen lassen + Fade-Dauer: %1$d ms Lautstärke während Wegpunkt: %1$d %% @@ -704,8 +738,8 @@ Viele Grüße MapLibre · Engine v2.4 - Routing & POI - Routing & Online-POI (MVP) + Tourverlauf & POI + Tourverlauf & Online-POI POI-Suche (online) z. B. „bäckerei", „museum", „bahnhof" Suchen @@ -713,64 +747,76 @@ Viele Grüße Keine POIs gefunden. Suche fehlgeschlagen — bitte Verbindung/Server prüfen. POI-Quelle: OpenStreetMap · Overpass/Nominatim · Bitte sparsam abfragen. - Als Wegpunkt - Ins Storyboard + Als Audio-Wegpunkt + In Tourverlauf %1$s entfernt - Routing-Endpunkt (OSRM-kompatibel) + Optionaler Routing-Endpunkt (OSRM-kompatibel) URL, z. B. https://router.project-osrm.org (Demo, Limits) oder eigene self-hosted Instanz. Speichern - Routing-Endpunkt nicht konfiguriert — Storyboard-Linie wird als Luftlinie/Planlinie angezeigt. + Kein Routing-Endpunkt — Tourverlauf wird als Planlinie (Luftlinie) angezeigt. Online-Routing optional. Demo-OSRM-Server (router.project-osrm.org) hat Rate-Limits und ist nicht für Produktion gedacht. - Route berechnen + Route berechnen (optional) Route wird berechnet … Routenberechnung fehlgeschlagen — Endpunkt prüfen. Mindestens zwei Wegpunkte im Storyboard nötig. Route geladen (%1$s · %2$s). Planlinie (Luftlinie). Für echte Straßenrouten Endpunkt konfigurieren. - - Routenplanung - Start, Via-Punkte und Ziel wählen, Route berechnen oder Planlinie nutzen. - Routenplanung öffnen - Routenplaner - Start - Via-Stopps - Ziel - Noch kein Start gewählt - Noch kein Ziel gewählt - Keine Via-Stopps. Beliebige Zwischenstopps lassen sich oben hinzufügen. - Start wählen - Ziel wählen - Via-Stopp hinzufügen - Plan zurücksetzen - Route berechnen - Nur Planlinie zeigen - Planlinie wird angezeigt. - Planlinie ist eine Luftlinie zwischen den Stops — sie folgt keinen Straßen. - Route: %1$s · %2$s - Planlinie: %1$s (Luftlinie) - Als Tour-Entwurf speichern - Tour-Entwurf %1$s gespeichert. - Tour-Entwurf konnte nicht gespeichert werden. - Tour-Entwurf speichern + + Tourverlauf erstellen + Zeichne zuerst den Verlauf deiner Tour. Audio-Punkte setzt du danach. + Tourverlauf öffnen + Route + Start der Route + Route über Punkte formen + Ziel der Route + Wo beginnt die Tour? + Wo endet die Tour? + Noch keine Punkte gesetzt. Tippe Punkte auf die Karte, um den Verlauf zu lenken. Keine Audio-Punkte. + Start der Route wählen + Ziel der Route wählen + Punkt hinzufügen + Verlauf zurücksetzen + Online-Route berechnen + Verlaufslinie anzeigen + Routenlinie wird angezeigt (Luftlinie zwischen den Tourpunkten). + Routenlinie = direkte Linie zwischen den Tourpunkten. Für Straßen-Routing: optionalen OSRM-Endpunkt konfigurieren. + Berechnete Route: %1$s · %2$s + Tourlinie: %1$s (Luftlinie) + Als Tourentwurf speichern + Tourentwurf %1$s gespeichert. + Tourentwurf konnte nicht gespeichert werden. + Tourentwurf speichern Name der neuen Tour - Punkte als Wegpunkte übernehmen + Tourpunkte mit Audio versehen + + Tourverlauf frei zeichnen + Das ursprüngliche Werkzeug: Linie direkt auf der Karte zeichnen — kein Netz, kein Server nötig. Ideal für freie Routen abseits der Straße. (Intern auch als Fallback verfügbar, wenn kein Routing möglich ist.) + Tourverlauf frei zeichnen + + Route über Punkte formen + Tippe Punkte auf die Karte, um den Verlauf zu lenken. Das erzeugt keine Audio-Punkte. Start/Ziel müssen zuerst gesetzt sein. + Punkt auf Karte tippen + Modus aktiv — nächster Tipp auf die Karte setzt einen Formungspunkt. + Zuerst Start und Ziel setzen, dann Punkte ergänzen. + Karte antippen → Zwischenpunkt setzen (kein Audio-Waypoint). Abbrechen: Cancel. %1$d Wegpunkte hinzugefügt. - Noch keine berechnete Route. Endpunkt konfigurieren und berechnen, oder Planlinie nutzen. - Routing-Endpunkt nicht konfiguriert — nur Planlinie verfügbar. - Endpunkt einrichten - Bereit. Stops wählen und berechnen. + Noch kein Verlauf sichtbar. Freie Linie zeichnen oder Punkte setzen — Online-Routing ist optional. + Kein Routing-Endpunkt gesetzt. Online-Routing ist optional. + Routing-Endpunkt einrichten + Tourpunkte setzen, dann Routenlinie anzeigen. Online-Routing ist optional. Start - Via %1$d + Formungspunkt %1$d + Formungspunkte Ziel - Quelle wählen + Punkt-Quelle wählen Aktueller Standort Aktueller Standort: noch keine Position Kartenmitte - Vorhandenen Wegpunkt wählen + Audio-Wegpunkt als Tourpunkt Keine Wegpunkte in dieser Tour Online-POI suchen - Eigene Koordinate eingeben + Koordinate manuell eingeben Ortssuche (Nominatim) Koordinate eingeben Breitengrad (Lat) @@ -783,65 +829,102 @@ Viele Grüße Mein Standort Kartenmitte Eigene Koordinate - Routing fehlgeschlagen — Endpunkt prüfen. - Routing fehlgeschlagen: %1$s — Endpunkt oder Netzwerk prüfen. - Kein Routing-Endpunkt gesetzt. Mit „OSRM-Demo nutzen\" startest du sofort einen Testlauf, oder richte unter „Endpunkt einrichten\" einen eigenen Server ein. - OSRM-Demo nutzen - Setzt einen öffentlichen OSRM-Testserver (router.project-osrm.org). Nur für Tests/Demo, Rate-Limits, keine Garantie. Bitte sparsam verwenden. - Demo-Endpunkt aktiv (router.project-osrm.org). Jetzt „Route berechnen\" drücken. + Online-Routing fehlgeschlagen — Endpunkt prüfen. Planlinie weiterhin verfügbar. + Online-Routing fehlgeschlagen: %1$s — Endpunkt oder Netzwerk prüfen. Planlinie weiterhin verfügbar. + Kein Routing-Endpunkt gesetzt — Planlinie (Luftlinie) ist verfügbar. Optional: Mit „OSRM-Demo“ starten oder eigenen Server einrichten. + OSRM-Demo (optional, experimentell) + Setzt einen öffentlichen OSRM-Testserver (router.project-osrm.org). Nur für Tests/Demo, Rate-Limits, keine Garantie. Online-Routing ist nicht notwendig — die Planlinie genügt für den Tourverlauf. + Demo-Endpunkt aktiv (router.project-osrm.org). Jetzt „Route berechnen“ drücken oder Planlinie nutzen. OSRM-Demo-URL einsetzen Erlaubt: Basis-URL (https://router.project-osrm.org) oder vollständig mit Pfad /route/v1/driving. Wird intern normalisiert. - Routen-Darstellung - Route/Planlinie anzeigen - Wenn aktiviert, werden berechnete Route, Planlinie und Stop-Marker auf der Karte gezeichnet. - Berechnete Route + Routenlinie auf Karte + Tourlinie / Planlinie anzeigen + Wenn aktiviert, werden Routenlinie und Tourpunkt-Marker auf der Karte gezeichnet. + Berechnete Route (optional) Planlinie (Luftlinie) - Als Start - Als Via - Als Ziel + Als Startpunkt + Als Tourpunkt + Als Endpunkt Punkt verwenden - Treffer wird als Stopp dem Routenplaner hinzugefügt. + Treffer wird als Tourpunkt der Route hinzugefügt. Gesuchten Ort übernehmen - Routing optional via OSRM-kompatiblem Endpunkt — sparsam abfragen. - + Online-Routing optional — Planlinie braucht keinen Server. Sparsam abfragen. + Fortbewegungsmittel Auto Fahrrad Fußgänger - Wird für die Routing-Anfrage verwendet (driving/cycling/walking) und mit der gespeicherten Tourroute übernommen. - Routing für „%1$s\" fehlgeschlagen: %2$s — Endpunkt oder Netzwerk prüfen. - Kein Routing-Endpunkt gesetzt (Profil: %1$s). Mit „OSRM-Demo nutzen\" startest du sofort einen Testlauf, oder richte unter „Endpunkt einrichten\" einen eigenen Server ein. + Nur relevant wenn Online-Routing (OSRM) genutzt wird. Planlinie benötigt kein Profil. + Online-Routing für „%1$s“ fehlgeschlagen: %2$s — Endpunkt oder Netzwerk prüfen. + Kein Routing-Endpunkt gesetzt (Profil: %1$s) — Planlinie verfügbar. Optional: OSRM-Demo oder eigener Server. Profil: %1$s - - Tourroute „%1$s" - Keine Tourroute gespeichert. + + Gespeicherter Tourverlauf „%1$s“ + Kein Tourverlauf gespeichert. aktualisiert %1$s Quelle: %1$s - Tourroute speichern - Tourroute ersetzen - Tourroute löschen - Erst Route berechnen oder Planlinie zeigen — dann lässt sich die Tourroute dauerhaft speichern. - Tourroute für „%1$s" gespeichert. - Tourroute für „%1$s" ersetzt. - Tourroute für „%1$s" gelöscht. - Gespeicherte Tourroute geladen (%1$s). + Tourverlauf speichern + Tourverlauf ersetzen + Tourverlauf löschen + Erst Planlinie zeigen oder Route berechnen — dann kann der Tourverlauf dauerhaft gespeichert werden. + + Experimentelles Online-Routing + Abhängig von externem OSRM-Dienst. Offline nicht nötig — freie Linie und Punkte formen funktionieren ohne Netz. + + Als Tourverlauf übernehmen + Speichert die aktuelle Planlinie (oder berechnete Route) dauerhaft als Tourverlauf der aktiven Tour. + Tourverlauf übernommen (Planlinie). + Tourverlauf übernommen (berechnete Route). + Zuerst Start und Ziel setzen, dann Tourverlauf übernehmen. + Tourverlauf für „%1$s“ gespeichert. + Tourverlauf für „%1$s“ ersetzt. + Tourverlauf für „%1$s“ gelöscht. + Gespeicherter Tourverlauf geladen (%1$s). Planlinie GPS-Track - - Station hinzufügen - Route zeichnen - Route zeichnen: Tippe auf die Karte, um den Wegverlauf zu setzen. Nur Stationen spielen Audio ab. - Route zeichnen: Wegverlauf setzen. Nur Stationen spielen Audio ab. - Routenpunkt + + Audio-Wegpunkt hinzufügen + Tourverlauf zeichnen + Tourverlauf zeichnen: Tippe auf die Karte, um Verlaufspunkte zu setzen. Nur Audio-Wegpunkte lösen Klänge aus. + Tourverlauf: Verlaufslinie setzen. Nur Audio-Wegpunkte lösen Klänge aus. + Tourverlaufspunkt Verschieben Löschen - Tippe die neue Position an — der Routenpunkt wird dorthin verschoben. - Manuell gezeichnete Route - Manuelle Route gelöscht. - Manuelle Route gespeichert (%1$d Punkte). - Manuelle Route löschen + Tippe die neue Position an — der Verlaufspunkt wird dorthin verschoben. + + + + Route bearbeiten + Routenpunkte setzen · Audio später hinzufügen + Bearbeiten + Routenpunkte + Tourpunkte formen die Routenlinie. Sie lösen kein Audio aus. Audio wird über "Als Audio-Wegpunkt erstellen" bewusst hinzugefügt. + %1$d Routenpunkt(e) gesetzt + Noch keine Routenpunkte. Tippe auf die Karte oder nutze "Hier setzen". + Punkt hier setzen + Karte antippen (Punkte setzen) + Letzten Punkt entfernen + Verlauf löschen + Gesamten Tourverlauf löschen? + Audio hier hinzufügen + Erstellt einen Audio-Wegpunkt an diesem Tourpunkt. + Editor schließen + Route bearbeiten + Optional: Online-Route berechnen + OSRM/Straßen-Routing ist optional. Der manuelle Tourverlauf ist die Hauptmethode. + Route bearbeiten + + Route planen + Route planen + Start/Ziel/Via, Planlinie, Online-Routing (OSRM) + Planer öffnen + Tourverlauf gespeichert (%1$d Punkte). + Manuell gezeichneter Tourverlauf + Tourverlauf gelöscht. + Tourverlauf gespeichert (%1$d Punkte). + Tourverlauf löschen Manuell gezeichnet Neu in 2.3.1: Wegpunkt-Dialog spielt Clips jetzt direkt ab statt „auf Kartenmitte verschieben". Karten-UI deutlich entschlackt: nur noch Icon-FABs und kompakte Status-Chips. Clip-Längenlinien projizieren die Audiodauer als farbige Segmente auf die Karte (Layer schaltbar). Neue Routing & Online-POI-Sektion: konfigurierbarer OSRM-Endpunkt + Overpass-POI-Suche (frei, sparsam). MapLibre-Pipeline weiter vorbereitet; aktives Rendering bleibt osmdroid. @@ -951,4 +1034,17 @@ Viele Grüße als Wegpunkt angelegt. Neu in 2.5.40: Echter Explorer-POI-Layer über Overpass-API — Sehenswürdigkeiten, Gastronomie, Mobilität, Infrastruktur direkt aus OpenStreetMap laden (kostenlos, kein API-Key, rate-limit-freundlich, nur auf Nutzer-Aktion). Antippen eines POI-Markers öffnet Detail-Sheet mit Name, Kategorie, Entfernung, Tags und Option „Als Wegpunkt“. Kompass-Button in der rechten Werkzeugleiste: setzt Karten-Ausrichtung auf Norden (Bearing 0) mit Animation. Rechte Werkzeugleiste neu geordnet — kein Überlappen der Buttons mehr. Alle v2.5.39-Fixes (Scroll, Attribution, Kartenstil-Persistenz) bleiben erhalten. + + Audio vorhanden + Kein Audio (nur Routenpunkt) + Audio hinzufügen + Audio bearbeiten + + Neu in 2.7.17 (Route-first Explorer): Die Planung folgt jetzt dem mentalen Modell „Route zuerst, Audio danach". Der Routen-Planer heißt jetzt „Route planen"; Verlaufspunkte werden als „Tourpunkte" bezeichnet. Der „Tourverlauf bearbeiten"-Editor wird als „Route bearbeiten" geführt. Audio wird bewusst nachträglich per „Audio hier hinzufügen" einem Tourpunkt zugewiesen. Bestehende Audio-Wegpunkte bleiben vollständig erhalten und abwärtskompatibel. Keine Änderung an Logik, Backup, Lizenz, MapLibre-Rendering, Waypoint-Playback oder GPS-Aufzeichnung. + + + Neu in 2.7.18 (Unified Tour Layers): Routing, manuelles Zeichnen und Audio-Wegpunkte wirken jetzt als ein gemeinsames Tour-System. Die manuelle Tourlinie wird nicht mehr doppelt gezeichnet — im gespeicherten Zustand zeigt die Karte eine einheitliche grüne Tourlinie (egal ob manuell gezeichnet oder per OSRM berechnet). Die violette Bearbeitungs-Vorschau erscheint nur noch im aktiven Zeichenmodus. Audio-Trigger-Zonen bleiben als Radius erhalten. Keine Änderung an Logik, Backup, Lizenz, MapLibre-Rendering, Routing, Waypoint-Playback oder GPS-Aufzeichnung. + Neu in 2.7.20 (Flexible Routenformung): Der Tourplaner ist jetzt klar auf “Route formen” ausgerichtet. Zwischenpunkte lenken die Route – sie sind keine Audio-Punkte. Neu: Zwischenpunkt direkt auf der Karte setzen (Schaltfläche “Zwischenpunkt auf Karte tippen”). Die freie Linie ist klar als Fallback gekennzeichnet. Start/Ziel/Via-Persistenz aus v2.7.19 bleibt vollständig erhalten. Keine Änderung an Audio-Waypoints, Multiclip, PTT, Atmo, Backup, Lizenz oder MapLibre-Rendering. + Neu in 2.7.19 (Tourplaner Integration Fix): Start, Ziel und Via-Ziele bleiben im Tourplaner dauerhaft gespeichert — auch nach App-Neustart. Der Tourplaner schreibt den Plan jetzt sofort in die Datenbank, sobald Start und Ziel gesetzt sind. Außerdem: Manuelles Zeichnen ist direkt im Tourplaner zugänglich (Schaltfläche “Zeichenmodus öffnen”). Routing per OSRM und manuelles Zeichnen sind gleichwertige Bearbeitungsmethoden im selben Planer. Die violette Vorschaulinie erscheint weiterhin nur im aktiven Zeichenmodus (kein Rückschritt in 2.7.18). + diff --git a/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_handoff.md b/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_handoff.md new file mode 100644 index 0000000..c487075 --- /dev/null +++ b/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_handoff.md @@ -0,0 +1,86 @@ +# GPS2Audio v2.7.38 — Handoff (Audio Regression Fix) + +**Date:** 2026-06-22 +**Base:** v2.7.37 (versionCode 143) → **v2.7.38 (versionCode 144)** +**Source:** `/home/user/workspace/GPS2Audio_v2737_from_gitea/src` +**Priority honored:** Audio reliability over preserving the silent gap. The gap is kept, but made strictly safe. + +--- + +## Suspected root cause + +The only functional change in v2.7.37 was a hidden ~1.7 s silent gap between manual sequence items, implemented as a delayed coroutine (`manualGapJob`) launched inside the `manualPlayer.play(...)` natural-completion callback. The gap itself is correctly scoped to post-completion auto-advance. The regression — "audio broken, especially on Samsung tablets" — comes from **two unsafe state interactions introduced/left open by that delayed job, plus a pre-existing silent-failure path that the gap made far more visible**: + +1. **Stale auto-advance across tour switch / tour delete.** + `selectTour()` deliberately preserves running playback (v2.5.30) and did **not** cancel a pending gap job. `deleteTour()` stopped the player but also did **not** cancel the gap job. So a gap job scheduled in tour A could fire ~1.7 s later after the user switched to / deleted tour A, calling `playFromCurrentPlayable(autoAdvance=true)` and setting `_manualPlaying=true` against stale `_currentPlayable` — leaving the UI in a "playing" state with the wrong audio or no audio. + +2. **Gap job did not re-validate user intent before firing.** + The delayed job only re-checked PTT and whether the next item still exists. It did **not** check whether the user had paused/stopped or switched to single-clip mode during the silence, so it could resurrect playback the user had just stopped. + +3. **Silent load/start failure left state as "playing".** + `ManualAudioPlayer.loadAndPlay()` uses raw `MediaPlayer` with synchronous `prepare()`/`start()` and **no error feedback to the caller**. On some devices (notably Samsung tablets) `prepare()`/`start()` on `content://` SAF URIs, or an async `MediaPlayer` error, fails — the player released silently while the ViewModel had already set `_manualPlaying=true`. Result: UI shows "playing", no sound, no error. The v2.7.37 gap (which chains auto-advance starts) made this dead-end far more reachable across a sequence. + +--- + +## Exact fixes + +### `app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt` +- **`playFromCurrentPlayable(autoAdvance)` — gap job hardened.** Capture `gapTour = _selectedTour.value` when scheduling. After the `delay`, before advancing, the job now aborts if: + - the selected tour changed during the silence (`_selectedTour.value != gapTour`) — no start in the wrong tour; + - the user is no longer playing or has entered single mode (`!_manualPlaying.value || manualPlayer.isSingleMode`) — no resurrection after pause/stop/preview. + PTT and "next item removed" checks unchanged. This makes the gap **strictly post-completion auto-advance within the same tour**. +- **`playFromCurrentPlayable` — `onError` wired.** `manualPlayer.play(...)` is now called with an `onError` callback that cancels any gap, resets `_manualPlaying`/`_manualCurrentName`/`_currentPlayable`, stops the ticker, calls `musicManager.afterWaypointAudio()`, and surfaces `_errorMessage` ("Audio konnte nicht abgespielt werden. Bitte erneut versuchen."). No more silent "playing-with-no-audio". +- **`selectTour()`** — cancels a pending gap job on an actual tour change (`if (safeTarget != _selectedTour.value) cancelManualGap()`), without stopping active playback (persistence preserved). +- **`deleteTour()`** — calls `cancelManualGap()` alongside the existing `manualPlayer.stop()` reset when the deleted tour is selected. + +### `app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt` +- **`play(...)`** gains an optional `onError: () -> Unit = {}` (placed before `onCompletion` so the trailing-lambda completion call sites stay valid). Fires `onError()` on blank URI. +- **`loadAndPlay(...)`** gains optional `onError`; fires it both on the synchronous `runCatching{}.onFailure` (prepare/start failure) and inside `setOnErrorListener` (async MediaPlayer error), after releasing resources. +- **`startSingleLoad(...)`** updated to named `onCompletion =` argument (behavior identical; single-clip preview/GPS unaffected since `onError` defaults to no-op). + +### `app/build.gradle.kts` +- `versionCode 143 → 144`, `versionName "2.7.37" → "2.7.38"`, with a v2.7.38 changelog comment block. + +**First manual playback is unchanged and still immediate** — the gap only exists in the auto-advance callback after a clip ends, never on the initial `startPlayableAt` → `playFromCurrentPlayable` path. `cancelManualGap()` is still called at the top of `startPlayableAt`, so Play/Resume/Next/Prev/Skip cannot be suppressed by a pending gap. + +--- + +## Playback paths reviewed + +| Path | Verdict | +|---|---| +| Manual sequence play/pause/resume/next/prev/stop | **Fixed/hardened** (above). `cancelManualGap()` confirmed on every control path. | +| Auto-advance silent gap (v2.7.37) | **Kept but made strictly safe** (same-tour, still-playing, post-completion only). | +| Single clip preview (`manualPlaySingleClip` / `playSingle` / `startSingleLoad`) | Reviewed — unchanged behavior; `onError` defaults to no-op. No change needed. | +| GPS-triggered playback (`service/AudioPlayer.kt`) | Separate class, not touched by these edits. No change needed. | +| PTT suppression / interruption coordinator | Reviewed — gap job and play paths still honor `pttManager.pttActive`; coordinator pause/resume unchanged. | +| Atmo / background music (`BackgroundMusicPlayer`, `musicManager`) | Not touched. `afterWaypointAudio()`/`beforeWaypointAudio()` calls preserved. | +| Background manual playback (`ManualPlaybackService`) | Not touched; start/stop/meta lifecycle unchanged. | +| Tour switch / delete playback persistence (v2.5.30) | Preserved — active playback still survives a plain tour switch; only the stale *pending gap* is cancelled. | +| Player auto-open (v2.7.32) | Not touched. | + +--- + +## Build / test results + +- **Signed APK: NOT BUILT — toolchain unavailable in this environment.** + - No Android SDK (`/home/user/tools/android-sdk` missing). + - No Gradle (`gradlew` execs `/home/user/tools/gradle/bin/gradle`, which does not exist; `/home/user/tools/` is absent). + - No keystore (`keystore.properties` points to `/home/user/workspace/GPS2Audio_release_keystore_v2.jks`, which is missing). + - The Gradle release signing config itself is intact in `app/build.gradle.kts` (reads `keystore.properties` or `GPS2AUDIO_RELEASE_*` env vars), so a signed universal release APK **will** build on a properly provisioned machine: `./gradlew :app:assembleRelease` with the keystore + SDK present. +- **Compilation:** could not run (no SDK/Gradle). Changes were made minimal and verified by manual review for Kotlin correctness: + - `play(...)` parameter order changed so the existing trailing-lambda completion call site still binds to `onCompletion`; the new ViewModel call passes `onError = { ... }` (named) + trailing `onCompletion` lambda. + - All three `loadAndPlay(...)` call sites match the updated 4-arg signature. +- **Recommended manual QA on a Samsung tablet:** (1) play a tour clip and let it auto-advance — verify ~1.7 s gap then next clip; (2) during the gap, switch tours — verify no audio starts in the new tour and no stuck "playing"; (3) during the gap, delete the tour — verify clean stop; (4) during the gap, tap pause/stop — verify it stays stopped; (5) force a bad/unreadable clip — verify error toast appears and state resets instead of silent "playing"; (6) first manual play — verify it starts immediately with no delay. + +--- + +## Artifacts + +- **Clean source ZIP:** `/home/user/workspace/GPS2Audio_v2738_artifacts/GPS2Audio_v2.7.38_src.zip` + - 136 files. Excludes `.gradle`, `.idea`, `.kotlin`, all `build/` dirs, APK/AAB/JKS/keystore binaries, `.class`/`.dex`, and `.git`. + - **Also excludes `keystore.properties` and `local.properties`** — `keystore.properties` contained plaintext signing passwords (do not redistribute); `local.properties` is machine-specific. Recreate both from the build machine's secrets when building. +- **This handoff:** `/home/user/workspace/GPS2Audio_v2738_artifacts/HANDOFF_v2.7.38.md` + +## Constraints honored +Preserved v2.7.32 (tour ordering + player auto-open), v2.7.33 (Begleitmusik UI), v2.7.34 (waypoint edit UI), v2.7.35 (home UI), v2.7.36 (backup export IO fix), v2.7.37 gap (kept, made safe), and Explorer/MapLibre/routing/POI/settings/license/PTT/Atmo/multiclip/background playback. No refactors, no data-model changes. diff --git a/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_source.zip b/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_source.zip new file mode 100644 index 0000000..7ad2b89 Binary files /dev/null and b/releases/v2.7.38/GPS2Audio_v2_7_38_samsung_audio_fix_source.zip differ