diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 4c943ba..0000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/ -.DS_Store -/build -/captures -.externalNativeBuild -.cxx -local.properties -app/build/ diff --git a/LICENSE b/LICENSE index 9935400..3de013c 100644 --- a/LICENSE +++ b/LICENSE @@ -191,3 +191,34 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + + -------------------------------------------------------------------- + Commons Clause License Condition v1.0 + -------------------------------------------------------------------- + + "Commons Clause" License Condition v1.0 + + The Software is provided to you by the Licensor under the License, + as defined above, subject to the following condition. + + Without limiting other conditions in the License, the grant of + rights under the License will not include, and the License does not + grant to you, the right to Sell the Software. + + For purposes of the foregoing, "Sell" means practicing any or all of + the rights granted to you under the License to provide to third + parties, for a fee or other consideration (including without + limitation fees for hosting or consulting/support services related + to the Software), a product or service whose value derives, entirely + or substantially, from the functionality of the Software. Any + license notice or attribution required by the License must also + include this Commons Clause License Condition notice. + + Software: GPS2Audio + License: Apache License 2.0 + Licensor: Marcel Mayer (NesoHub) + + See `TERMS.md` (German, user-facing) for the project-specific + interpretation of "Sell" — explicitly including paid tours, + SaaS / hosted services, and integration into commercial offerings. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..ca3dbc9 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,44 @@ +# NOTICE — GPS2Audio + +GPS2Audio +Copyright 2024–2026 Marcel Mayer (NesoHub). + +This product is licensed under the **Apache License, Version 2.0** +("Apache 2.0") with the addition of the **Commons Clause License +Condition v1.0**. + +You may obtain a copy of Apache 2.0 at: + https://www.apache.org/licenses/LICENSE-2.0 + +The Commons Clause restricts the right to **Sell** the software +or to provide it as a commercial / hosted service. See `LICENSE` +and `TERMS.md` (German user-facing version) for the binding +project-internal interpretation, including the list of restricted +commercial use cases (paid tours, SaaS, B2B integration, etc.). + +## Third-party components + +The Android application ships with the following third-party +components, used under their respective licenses. Their licenses +are **not** overridden by the Commons Clause and apply to those +components only. + +| Component | License | Notes | +| --- | --- | --- | +| AndroidX / Jetpack libraries (core, lifecycle, activity, navigation, datastore, splashscreen) | Apache 2.0 | © The Android Open Source Project | +| Jetpack Compose, Material3, Material Icons Extended | Apache 2.0 | © The Android Open Source Project | +| Kotlin standard library & coroutines | Apache 2.0 | © JetBrains s.r.o. | +| Google Play services Location | Custom Google license | © Google LLC | +| AndroidX Media3 / ExoPlayer | Apache 2.0 | © The Android Open Source Project | +| osmdroid | Apache 2.0 | © osmdroid contributors | +| MapLibre Native Android SDK | BSD-2-Clause | © MapLibre contributors | +| Gson | Apache 2.0 | © Google LLC | +| OpenStreetMap raster tiles | © OpenStreetMap contributors, ODbL | Map data and tiles served by OSM and compatible providers | + +See `THIRD_PARTY_NOTICES.md` for the canonical, longer list and +attribution requirements. New third-party additions MUST be added +both there and here, and the in-app About screen MUST keep its +Apache 2.0 + Commons Clause notice intact. + +This NOTICE file itself is provided as required by Section 4(d) of +the Apache License 2.0. diff --git a/RELEASES.md b/RELEASES.md index 681e810..048fecb 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -26,6 +26,14 @@ Release: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.3 | `v2.1.1` | PTT-Aufnahmen anhören und zuweisen | Aufnahmen können in der Bibliothek angehört und Wegpunkten zugewiesen werden; neue Wegpunkte können aus Aufnahmen erstellt werden. | | `v2.1.2` | Automatisches Speichern | Nach dem Loslassen der PTT wird ohne störende Nachfrage automatisch im Hintergrund gespeichert. | | `v2.1.3` | Sichtbarer Löschen-Button | Die PTT-Aufnahmen-Bibliothek erhielt einen klar sichtbaren Löschen-Button pro Aufnahme. | +| `v2.4.0` | MapLibre Engine Cutover | Aktiver Renderer im Explorer wurde auf MapLibre Native umgestellt. osmdroid bleibt nur noch als interner Fallback erhalten. | +| `v2.4.1` | Map-Stile und Terrain | Modern hell, Dunkel, Topografisch, Terrain/3D und Satellit als auswählbare Stile (persistent). | +| `v2.4.2` | Explorer-Routenplanung | Eigener Routenplaner direkt im Explorer: Start/Via/Ziel aus mehreren Quellen, OSRM-Routing optional, Planlinien-Fallback ohne Endpunkt, Routen als Tour-Entwurf speicherbar. | +| `v2.4.3` | Routenplaner Layout-Hotfix | Reines UI-/Layout-Fix: BottomSheet wird scrollbar, respektiert die System-Navigationsleiste (`navigationBarsPadding`), und die primären Aktionen 'Route berechnen' / 'Planlinie' liegen in einer Sticky Bottom Action Bar oberhalb der Gestenleiste. Keine neuen Features, keine API-Änderungen. | +| `v2.4.4` | Routenplaner Endpunkt-Hotfix | „Route berechnen" ist nicht mehr stumm/grau, wenn kein Endpunkt gesetzt ist: der Button reagiert mit klarem Statushinweis. Im Sheet steht zusätzlich ein One-Tap-Button „OSRM-Demo nutzen", der den öffentlichen Testserver hinterlegt. Die Endpunkt-URL wird normalisiert — sowohl `https://router.project-osrm.org` als auch die vollständige `/route/v1/driving`-Variante werden akzeptiert (keine Doppelprefixe). Fehler werden in Deutsch mit Detail angezeigt. Keine Layout-Regressionen. | +| `v2.4.5` | Karten-UX-Hotfix Clip-Linie und Planlinie | Reiner Karten-UX-Hotfix. Der Clip-Längenabschnitt beginnt jetzt AM Waypoint und erstreckt sich nach vorne entlang der Route/Track-Geometrie — nicht mehr symmetrisch um den Projektionspunkt. Damit wirkt der Waypoint visuell als Trigger-Start des Clips. Zusätzlich wird die Luftlinie/Planlinie nur noch angezeigt, wenn keine berechnete Route vorhanden ist; die Doppeldarstellung von Route und Planlinie ist entfernt. Legende im Routenplaner spiegelt den aktiven Layer (Route ODER Planlinie). Routing-/Endpunkt-Verhalten und Layout aus v2.4.4/v2.4.3 unverändert. | +| `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. | ## Release-Links diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..12e9507 --- /dev/null +++ b/TERMS.md @@ -0,0 +1,115 @@ +# Nutzungsbedingungen GPS2Audio + +**Version:** 2.5.11-terms-1 +**Stand:** 27. Mai 2026 + +Diese Nutzungsbedingungen ergänzen die Lizenzbedingungen +(Apache License 2.0 + Commons Clause License Condition v1.0) +für die Anwendung „GPS2Audio“, ihren Quellcode, ihre Assets und +ihre Marke. Durch die Installation und Nutzung der App stimmst du +diesen Bedingungen zu. + +## 1. Lizenz und Modell + +GPS2Audio ist **source-available**. Der Code und die mitgelieferten +Assets von GPS2Audio (ausgenommen klar gekennzeichnete Drittanbieter- +Bibliotheken) stehen unter: + +- **Apache License 2.0**, **eingeschränkt durch die Commons Clause + License Condition v1.0**. + +Drittanbieter-Bibliotheken (z. B. AndroidX, Material3, ExoPlayer/Media3, +osmdroid, MapLibre, Gson, kotlinx.coroutines, OkHttp/HTTP-Clients, +Splash-Screen-Backport) behalten ihre **jeweilige Originallizenz**. +Siehe `NOTICE.md` und `THIRD_PARTY_NOTICES.md`. + +## 2. Erlaubte Nutzung + +Erlaubt sind insbesondere: + +- **Private, nicht-kommerzielle Nutzung** der App und der gelieferten + signierten Builds. +- **Forks**, eigene **Modifikationen** und **Weitergabe des Quellcodes** + unter Beibehaltung dieser Lizenzhinweise sowie der Commons-Clause- + Beschränkung. +- Erstellen und Teilen **eigener Touren** für den persönlichen Gebrauch + oder im Rahmen eindeutig nicht-kommerzieller Aktivitäten (z. B. + private Wanderungen, kostenlose Vereinsveranstaltungen ohne Gegen- + leistung, kostenfreie Lehre). + +## 3. Untersagte kommerzielle Nutzung (Commons Clause) + +Ohne ausdrückliche **vorherige schriftliche Genehmigung** des +Entwicklers ist jede **kommerzielle Nutzung** untersagt. Untersagt sind +insbesondere – aber nicht ausschließlich: + +- **Verkauf** der App, von Builds, von Forks oder von Quellcode- + Bestandteilen, auch als „Pro“-/„Premium“-Variante. +- **SaaS / Hosted Service**: das Anbieten von GPS2Audio (auch in + modifizierter Form) als Online-Dienst, Backend-Service oder + Plattform-Bestandteil gegen Entgelt, gegen Werbung oder gegen + Nutzerdaten als Gegenleistung. +- **Bezahlte Touren / Tourismusangebote**: kommerzielle Stadt-, Bus-, + Schiff-, Wander-, Rad- oder vergleichbare geführte Touren, bei denen + GPS2Audio direkt oder indirekt Bestandteil eines bezahlten oder + beworbenen Produkts ist. +- **Integration in kommerzielle Angebote** Dritter (B2B-Whitelabel, + ÖPNV-/Verkehrsverbund-Apps, Hotel-/Resort-Gästeapps, App-Bundles). +- Re-Branding und Wiederveröffentlichung des Quellcodes in App-Stores, + insbesondere kostenpflichtig oder werbefinanziert. + +Für solche Nutzungen ist ein **separater Lizenz- bzw. Kooperations- +vertrag** mit dem Entwickler erforderlich. Anfragen bitte per +E-Mail an den Entwickler. + +## 4. Marke, Logo, Builds + +Markenname „GPS2Audio“, das Logo, die offiziellen signierten Builds +sowie kuratierte Tour-Inhalte unterliegen zusätzlich diesen +Nutzungsbedingungen und sind **nicht** durch die Apache License 2.0 +freigegeben. + +## 5. Datenschutz / Standortdaten + +GPS2Audio verarbeitet Standortdaten **ausschließlich lokal** auf dem +Endgerät, um Wegpunkte zu erkennen und Audio abzuspielen. Es findet +keine serverseitige Standortspeicherung durch den Entwickler statt. +Karten-Tiles werden direkt von den jeweiligen Anbietern +(OpenStreetMap-basiert) geladen; deren Datenschutzhinweise gelten +zusätzlich. + +## 6. Haftungsausschluss + +Die Software wird **„wie sie ist“ (as is)** und ohne jegliche +Gewährleistung bereitgestellt, im Rahmen dessen, was geltendes Recht +zulässt. Eine Haftung für Schäden – insbesondere für Folgen einer +Standort- oder Audio-Fehlfunktion, von Datenverlust oder von +Personen- oder Sachschäden während der Nutzung der App im Freien +(Wandern, Radfahren, Bootfahren, Autofahren) – ist ausgeschlossen, +soweit gesetzlich zulässig. + +Die App ist **kein Sicherheits- oder Navigationssystem**. Bediene +GPS2Audio nicht während Tätigkeiten, bei denen Unaufmerksamkeit zu +Personenschäden führen kann (z. B. Bedienen eines Fahrzeugs). + +## 7. Zustimmungsmechanismus + +Mit dem **erstmaligen Start** der App und nach **Version­upgrades +dieser Nutzungsbedingungen** wird ein **In-App-Dialog** angezeigt, in +dem du diesen Bedingungen aktiv zustimmen musst. + +- Die akzeptierte Version wird lokal mit der ID + `2.5.11-terms-1` gespeichert. +- Ohne Zustimmung **kann GPS2Audio nicht genutzt werden** und beendet + sich nach Ablehnung. +- Wird in einer späteren App-Version eine neue Terms-Version + hinterlegt, erscheint der Dialog erneut. + +## 8. Kontakt + +Kommerzielle Nutzung, Lizenzanfragen, Tourismus-Kooperationen: +E-Mail an den Entwickler (siehe „Über diese App“). + +— +GPS2Audio © Marcel Mayer. +Apache License 2.0 + Commons Clause License Condition v1.0. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..8519dbd --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,82 @@ +# Third-party notices — GPS2Audio + +GPS2Audio is licensed under Apache License 2.0 + Commons Clause +License Condition v1.0 (see `LICENSE`, `NOTICE.md`, `TERMS.md`). + +The third-party components below are NOT covered by the Commons +Clause; they remain under their original licenses. The user-facing +"About" screen in GPS2Audio refers users to this file as the +canonical attribution source. + +## AndroidX / Jetpack + +- Components: `androidx.core:core-ktx`, `androidx.lifecycle:*`, + `androidx.activity:activity-compose`, + `androidx.compose.*`, `androidx.compose.material3:material3`, + `androidx.compose.material:material-icons-extended`, + `androidx.navigation:navigation-compose`, + `androidx.datastore:datastore-preferences`, + `androidx.core:core-splashscreen`, + `androidx.media3:media3-exoplayer`, `androidx.media3:media3-common`. +- License: **Apache License 2.0**. +- Copyright: © The Android Open Source Project. +- URL: https://developer.android.com/jetpack + +## Kotlin standard library & coroutines + +- Components: `org.jetbrains.kotlin:*`, `org.jetbrains.kotlinx:kotlinx-coroutines-android`, + `org.jetbrains.kotlinx:kotlinx-coroutines-play-services`. +- License: **Apache License 2.0**. +- Copyright: © JetBrains s.r.o. +- URL: https://kotlinlang.org/ + +## Google Play services Location + +- Component: `com.google.android.gms:play-services-location`. +- License: Custom Google Play services license. See: + https://developers.google.com/android/guides/terms +- Copyright: © Google LLC. + +## Gson + +- Component: `com.google.code.gson:gson`. +- License: **Apache License 2.0**. +- Copyright: © Google LLC. +- URL: https://github.com/google/gson + +## osmdroid + +- Component: `org.osmdroid:osmdroid-android`. +- License: **Apache License 2.0**. +- Copyright: © osmdroid contributors. +- URL: https://github.com/osmdroid/osmdroid + +## MapLibre Native Android SDK + +- Component: `org.maplibre.gl:android-sdk`. +- License: **BSD 2-Clause License**. +- Copyright: © MapLibre contributors. +- URL: https://github.com/maplibre/maplibre-native + +## OpenStreetMap data / tiles + +- Map data and most raster tile sources used by the Explorer/Map + views are derived from **OpenStreetMap** data. +- License: **Open Database License (ODbL) 1.0** for data, + **CC BY-SA 2.0** for cartography of standard `tile.openstreetmap.org` + tiles. Map attribution must remain visible in the UI. +- Copyright: © OpenStreetMap contributors. +- URL: https://www.openstreetmap.org/copyright + +## Routing / POI providers (Nominatim, OSRM, Overpass) + +- The app may query public OSM-based services for geocoding, + routing and POI lookup (Nominatim, OSRM, Overpass). These are + community-provided endpoints; their respective usage policies apply. + +--- + +This list is canonical. Any new direct Maven/Gradle dependency +added to the project MUST be reflected here AND in `NOTICE.md`. +The user-visible "About" dialog points users to this file via +its license summary text. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3bcfc99..aea259f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,23 +1,189 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + load(FileInputStream(keystorePropertiesFile)) + } +} + +fun resolveSigningValue(propsKey: String, envKey: String): String? { + val fromProps = keystoreProperties.getProperty(propsKey) + if (!fromProps.isNullOrBlank()) return fromProps + val fromEnv = System.getenv(envKey) + if (!fromEnv.isNullOrBlank()) return fromEnv + return null +} + android { namespace = "de.waypointaudio" compileSdk = 35 + buildToolsVersion = "35.0.0" defaultConfig { applicationId = "de.waypointaudio" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "1.0" + // v2.5.18 — Bugfix: GPX-Import ist jetzt strikt additiv. + // - Datenverlust-Schutz: Der GPX-Import in v2.5.17 hat über + // `repository.saveAll(imported)` versehentlich alle bestehenden + // Wegpunkte in sämtlichen Touren ersetzt. Ab v2.5.18 löscht ein + // GPX-Import nie bestehende Daten. + // - Neuer Import-Dialog mit zwei Optionen: + // (A) Als neue Tour importieren (editierbarer Name, Default aus + // GPX-Dateiname). + // (B) Zu bestehender Tour hinzufügen (Auswahl der Tour). + // Abbrechen verändert nichts. + // - JSON-Import/-Export in der sichtbaren UI entfernt; das ZIP-Backup + // übernimmt diese Rolle. Interne JSON-Parser/-Modelle bleiben für + // Backup-Kompatibilität erhalten. + // - v2.5.17 Lizenz/Pro/About bleibt unverändert. + // - Alle weiteren Funktionen (Multi-Clip, Sequenzplayer, Countdown, + // maximierbarer Player, Tour duplizieren, Waypoint kopieren, + // Explorer/MapLibre/Routing, Backup ZIP, PTT/Atmo, Hintergrund, + // Tourcover) bleiben unverändert. + // + // v2.5.19 — Menü- und UX-Politur: + // • „Track Editor" aus dem Hauptmenü entfernt (über Explorer + // erreichbar). Funktionalität (TrackDraftListScreen/-Editor) + // bleibt vollständig im Code. + // • „Klassische Ansicht" umbenannt zu „Bearbeitungsmodus" — bessere + // UX-Begriffswahl auf Deutsch. + // • „Touren ordnen" aus der einzelnen Touransicht in das Menü der + // Audio-Bibliothek (HomeScreen) verschoben. Sortier-Funktion + // (TourReorderDialog + WaypointViewModel.moveTour) unverändert. + // • Backup: Tour-Cover (Icon + Palette + Bild) sind ab v2.5.7 + // bereits Teil des ZIP-Backups (media/covers/.jpg) und + // werden beim Import wiederhergestellt. v2.5.19 ist hierbei + // inhaltlich unverändert; dieser Eintrag dient der Klarstellung + // in der Versionshistorie. + // + // v2.5.20 — Phase 2 des Lizenzsystems: + // • Echter Import einer .gps2audio-license-Datei über das Android + // Storage Access Framework (ACTION_OPEN_DOCUMENT). + // • Offline-Signaturprüfung (ECDSA P-256 SHA-256) gegen den bereits + // in res/raw eingebetteten Public Key, kanonisches JSON wie im + // Python-Tool (sortierte Keys, kein Whitespace). + // • Lokale Ablage der validierten Lizenz in filesDir/license/ + // inkl. Backup-Ausschluss (data_extraction_rules + backup_rules). + // • Anzeige im About-Dialog: Status (Keine/Gültig/Ungültig/Abgelaufen), + // Lizenztyp, Lizenznehmer, Gültigkeit, Lizenz-ID, Bindung, + // Permissions (deutsch beschriftet). + // • Buttons „Lizenz importieren / exportieren / entfernen" inkl. + // Bestätigungsdialog. Keine Feature-Sperren, alle bestehenden + // Funktionen bleiben unverändert nutzbar. + // v2.5.21 — Re-Import-Bugfix + Geräte-ID: + // • Behebt: Lizenzdatei konnte nach Entfernen + Re-Import nicht mehr + // gelesen werden. Ursache: LicenseStatusProvider.initialized-Flag + // wurde nach remove() nicht zurückgesetzt; kombiniert mit + // transienten SAF-URI-Permissions führte das zu "Lizenzdatei konnte + // nicht gelesen werden" beim zweiten Import-Versuch. + // • Fix: initialized wird in remove() auf false gesetzt. import() + // liest Bytes immer frisch, ohne gecachte URIs. + // • Geräte-ID: app-spezifische UUID (SharedPreferences, kein Backup), + // SHA-256-Hash "GPS2AudioDevice:"+uuid für Lizenz-Tool-Kompatibilität. + // Anzeige + Kopier-Button im Über-Dialog. Keine harte Bindung aktiv. + // v2.5.23 — Lizenzimport-Regression-Fix: + // • v2.5.22 wechselte zu GetContent(), was auf manchen OEM-Geräten + // (Huawei/EMUI, MIUI-Galerie-Modus) nur Medien anzeigt — .gps2audio- + // license-Dateien unsichtbar, Erstimport unmöglich. + // • Fix: Zurück zu OpenDocument() mit breiten MIME-Typen (json/text/ + // octet-stream/*). Echter SAF-Datei-Browser zeigt alle Dateien. + // • importNonce-Mechanismus: Key-basierter Launcher-Slot-Reset erzwingt + // frischen SAF-Picker auch bei Wiederwahl derselben URI. + // • Robustes Lesen (copyTo statt readBytes), differenzierte + // Fehlermeldungen und reloadTrigger aus v2.5.22 beibehalten. + // v2.5.24 — Lizenzimport-Fallbacks: + // • readLicenseBytes(): drei Lesestrategien (openInputStream, openFileDescriptor, + // File-Fallback) mit separater Fehlersammlung. + // • Diagnose-Dialog bei Totalausfall: technische Fehlermeldungen statt generisches Toast. + // • Textimport-Fallback: Button "Lizenz aus Zwischenablage / Text einfügen" öffnet + // AlertDialog mit großem Textfeld + Zwischenablage-Schaltfläche. + // • OpenDocument() mit nur "*/*" (keine MIME-Liste, verhindert Galerie-Modus auf MIUI). + // • importNonce-Mechanismus aus v2.5.23 unverändert beibehalten. + // v2.5.25 — License-Import-Thread-Fix: + // • Alle blockierenden I/O-Operationen (contentResolver.openInputStream, + // openFileDescriptor, FileInputStream, ByteArrayOutputStream.copyTo, + // LicenseStatusProvider.import inkl. JSON/Signaturvalidierung) auf + // Dispatchers.IO verschoben. Root Cause: NetworkOnMainThreadException + // auf Android 9+ (StrictMode blockiert Netzwerk-/Disk-I/O auf Main-Thread). + // • ActivityResult-Callback startet Coroutine (rememberCoroutineScope); + // withContext(Dispatchers.IO) für readLicenseBytes + import(); + // danach UI-State/Toast/reloadTrigger auf Main-Thread. + // • isImporting-Flag verhindert Doppelauslösung des Import-Buttons. + // • Export-Callback (openOutputStream) ebenfalls auf IO. + // • Textimport: import() ebenfalls auf IO. + // • Alle v2.5.24-Fallbacks (drei Lesestrategien, Diagnose-Dialog, + // Textimport, OpenDocument */*, importNonce) unverändert. + // v2.5.29 — Lizenz-Persistenz-Fix: Update-sichere Lizenzablage. + // v2.5.30 — Home Atmo-Modul entfernt; Playback-Persistenz beim Tour-Wechsel. + // • Atmo-Modul/-Toggle auf der Startseite (HomeScreen) entfernt. + // Atmo bleibt vollständig in der Tour-Ansicht (WaypointListScreen) verfügbar. + // • selectTour() stoppt keine laufende Wiedergabe mehr beim Tour-Wechsel. + // Stop nur noch bei expliziten Nutzeraktionen: Play in anderer Tour, + // Stop-Button, GPS-Dienst bewusst stoppen. + // + // v2.5.31 — Tourcover-Backup-Fix: frei gewählte Bilder werden jetzt + // zuverlässig gesichert und beim Import wiederhergestellt. + // • BackupExportManager: ZIP-Entry-Pfade für Cover-Bilder erhalten + // einen eindeutigen Index-Präfix ("media/covers/001_tourname.jpg"), + // damit Touren mit gleichem sanitisierten Namen nicht kollidieren. + // • BackupExportManager: Fallback-Lesestrategie für Cover-Bilder: + // absoluter Dateipfad → ContentResolver → file://-URI. + // • BackupImportManager: Verbesserte Fehlerprotokollierung beim + // Cover-Bild-Import; Rückwärtskompatibel mit v2.5.7–v2.5.30-Backups. + // • Kein Änderungen am Lizenzsystem, Feature-Gates, Touren/Waypoints. + // v2.5.34 — Explorer-Kontext-Fix: selectDefaultTour() beim Einstieg aus + // der Audio-Bibliothek/Home verhindert das Übernehmen des zuletzt aktiven + // Tour-Kontexts. Einstieg aus konkreter Tour behält Tour-Karte. + // v2.5.38 — Kartenattribution, POI-Layer, Kartenstil-Persistenz. + // • Kartenattribution rechtlich sauber: kompaktes Badge bottom-left + // (anklickbar → openstreetmap.org/copyright), vollständiger Karteninfo- + // Dialog aus dem Ebenen-Sheet (OpenFreeMap, OpenMapTiles, OSM, ODbL). + // • POI-Layer-Kategorien im Ebenen-Sheet: Sehenswürdigkeiten (default an), + // Infrastruktur, Gastronomie, Mobilität (default aus). Symbol-Layer des + // geladenen MapLibre-Styles werden per Layer-ID-Heuristik ein-/ausgeblendet. + // Auswahl wird persistent gespeichert (DataStore). Kein Crash wenn Layer fehlt. + // • Kartenstil-Persistenz: baseStyle zuverlässig beim Neustart wiederhergestellt. + // POI-Auswahl ebenfalls persistent. Alle MapStyleStore-Keys unverändert. + // • Stylewechsel stellt Overlays, Attribution und POI-Sichtbarkeit wieder her. + // • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, + // PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing, + // Tour-/Waypoint-Kopien, Hintergrundwiedergabe. + versionCode = 98 + versionName = "2.5.44" + } + + val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE") + val storePassword = resolveSigningValue("storePassword", "GPS2AUDIO_RELEASE_STORE_PASSWORD") + val keyAliasValue = resolveSigningValue("keyAlias", "GPS2AUDIO_RELEASE_KEY_ALIAS") + val keyPasswordValue = resolveSigningValue("keyPassword", "GPS2AUDIO_RELEASE_KEY_PASSWORD") + val hasReleaseSigning = storeFilePath != null && storePassword != null && + keyAliasValue != null && keyPasswordValue != null + + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = file(storeFilePath!!) + this.storePassword = storePassword + keyAlias = keyAliasValue + keyPassword = keyPasswordValue + } + } } buildTypes { release { + if (hasReleaseSigning) { + signingConfig = signingConfigs.getByName("release") + } isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -38,6 +204,11 @@ android { buildFeatures { compose = true } + + lint { + abortOnError = false + checkReleaseBuilds = false + } } dependencies { @@ -58,7 +229,16 @@ dependencies { implementation(libs.kotlinx.coroutines.play.services) implementation(libs.play.services.location) implementation(libs.osmdroid) + // v2.2.2 — MapLibre Native Android SDK als Build-Abhängigkeit eingezogen. + // Das eigentliche Rendering nutzt weiterhin osmdroid (siehe ExplorerScreen), + // damit Stabilität/Größe nicht regressieren. MapLibre dient als Grundlage + // für künftige Vektor-/3D-Layer-Schritte (siehe Handoff). + implementation(libs.maplibre.android.sdk) implementation(libs.media3.exoplayer) implementation(libs.media3.common) + // v2.5.8 — Android Splash Screen API (Backport für API 26+). Wir nutzen + // den stabilen Weg via androidx.core.splashscreen, damit Pre-31 dieselbe + // Intro-Optik bekommt wie API 31+. + implementation(libs.androidx.core.splashscreen) debugImplementation(libs.androidx.ui.tooling) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ec779d..657acd4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,8 @@ + + @@ -38,11 +40,13 @@ + android:theme="@style/Theme.WaypointAudioGuide.Splash"> + + + + + + + wakeLockController.applySettings(settings) + } + } + lifecycleScope.launchWhenStarted { + viewModel.serviceRunning.collect { running -> + wakeLockController.setTourActive(running) + } + } + + // v2.5.9 — Terms-Akzeptanz laden. Solange unklar, UI nicht aufdecken. + val termsStore = TermsStore(applicationContext) + lifecycleScope.launch { + val accepted = termsStore.acceptedVersion.first() + termsState.value = if (accepted == TermsVersions.CURRENT) 1 else 0 + } + // Standortberechtigungen prüfen und ggf. anfordern checkAndRequestLocationPermissions() setContent { WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) { val locationGranted by locationPermissionsGranted + val termsAccepted by termsState - if (locationGranted) { + // v2.5.9 — Solange der Akzeptanzstatus noch nicht geladen wurde + // (termsAccepted == -1) zeigen wir nichts; das verhindert ein + // kurzes "Aufflackern" der UI bevor der Dialog erscheint. + if (termsAccepted == -1) { + // bewusst leeres Theme — DataStore ist nach wenigen ms da. + } else if (termsAccepted == 0) { + TermsDialog( + onAccept = { + lifecycleScope.launch { + termsStore.setAccepted(TermsVersions.CURRENT) + termsState.value = 1 + } + }, + onDecline = { + // Ohne Zustimmung darf die App nicht weiter genutzt werden. + finishAndRemoveTask() + } + ) + } else if (locationGranted) { val navController = rememberNavController() NavHost( navController = navController, - startDestination = "list" + startDestination = "home" ) { + // v2.5.0 — neue Hauptseite "Audio-Bibliothek". + composable("home") { + HomeScreen( + viewModel = viewModel, + onOpenTour = { _ -> + // Tour wurde im HomeScreen bereits über + // viewModel.selectTour() aktiviert. Wir öffnen + // 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. + onOpenExplorer = { + viewModel.selectDefaultTour() + navController.navigate("explorer") + }, + // v2.5.19 — „Track Editor" wurde aus dem Hauptmenü + // entfernt; Drafts bleiben über den Explorer + // erreichbar. Die „drafts"-Route bleibt für den + // Explorer-Fluss bestehen. + onOpenClassicView = { navController.navigate("list") } + ) + } composable("list") { WaypointListScreen( viewModel = viewModel, - onNavigateToMap = { navController.navigate("map") } + onNavigateToMap = { navController.navigate("map") }, + onNavigateToDrafts = { navController.navigate("drafts") }, + onNavigateToExplorer = { navController.navigate("explorer") } ) } composable("map") { + // v2.2.3: Legacy-Editor — nur noch als interner Fallback erreichbar. + // Mode-Switch zwischen Karteneditor und Explorer entfällt, Explorer + // ist der zentrale Arbeitsbereich. MapScreen( viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + currentMode = null, + onModeChange = null + ) + } + composable("explorer") { + // v2.4.0 — MapLibre Engine Cutover: aktiver Renderer ist jetzt MapLibre. + // Die osmdroid-Variante in [ExplorerScreen] bleibt im Quelltext als + // dokumentierter Fallback erhalten. + ExplorerScreenMapLibre( + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onOpenInEditor = { /* abgelöst — Explorer übernimmt */ }, + onOpenDraft = { id -> navController.navigate("draft/$id") } + ) + // Hinweis: ExplorerScreen (osmdroid) bleibt aus Build-/Symbol- + // Kompatibilität in der Sourcetree. + @Suppress("unused") + val osmdroidFallbackRef: @androidx.compose.runtime.Composable () -> Unit = { + ExplorerScreen( + viewModel = viewModel, + onNavigateBack = { navController.popBackStack() }, + onOpenInEditor = { /* abgelöst — Explorer übernimmt */ }, + onOpenDraft = { id -> navController.navigate("draft/$id") } + ) + } + } + composable("drafts") { + TrackDraftListScreen( + viewModel = viewModel, + onOpenDraft = { id -> navController.navigate("draft/$id") }, + onNavigateBack = { navController.popBackStack() } + ) + } + composable( + route = "draft/{draftId}", + arguments = listOf(navArgument("draftId") { type = NavType.StringType }) + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("draftId") ?: "" + TrackDraftEditorScreen( + viewModel = viewModel, + draftId = id, onNavigateBack = { navController.popBackStack() } ) } @@ -105,6 +259,16 @@ class MainActivity : ComponentActivity() { if (locationPermissionsGranted.value) { requestOptionalPermissions() } + if (::wakeLockController.isInitialized) { + wakeLockController.onActivityResumed() + } + } + + override fun onPause() { + if (::wakeLockController.isInitialized) { + wakeLockController.onActivityPaused() + } + super.onPause() } // --------------------------------------------------------------------------- diff --git a/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt b/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt index f2dbcaf..db47e02 100644 --- a/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt +++ b/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt @@ -3,15 +3,61 @@ package de.waypointaudio import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import de.waypointaudio.license.LicenseStatusProvider /** * Application-Klasse – initialisiert globale Ressourcen. + * + * v2.5.29 — Lizenz-Persistenz-Fix: + * Beim Start des App-Prozesses wird die Lizenz sofort aus dem persistenten + * Speicher geladen (LicenseStatusProvider.reload auf einem Hintergrundthread). + * + * Hintergrund: LicenseStatusProvider ist ein Kotlin-object (Prozess-Singleton). + * Nach einem APK-Update startet Android einen neuen Prozess — das Singleton + * ist dann frisch initialisiert mit "Keine Lizenz" als Standardwert. + * Die Lizenzdatei liegt in filesDir/license/ und überlebt APK-Updates; sie muss + * aber aktiv geladen werden. Bisher geschah das nur, wenn der Nutzer den + * Über-Dialog öffnete. Durch das Vorladen hier ist der Status beim ersten + * Compose-Render bereits korrekt gesetzt. + * + * Implementierung: Thread (kein Coroutine-Framework nötig in Application.onCreate), + * um keine Blockierung des Hauptthreads durch Datei-I/O zu verursachen. + * LicenseStatusProvider.reload() ist thread-safe (MutableState-Update ist + * Compose-intern threadsicher; UI rekomponiert beim nächsten Frame). */ class WaypointApp : Application() { override fun onCreate() { super.onCreate() createNotificationChannels() + initMapLibre() + // v2.5.29 — Lizenzstatus vorladen, bevor die UI rendert. + // Läuft auf einem Hintergrundthread, um Datei-I/O vom Main-Thread fernzuhalten. + // ensureLoaded() ist @Synchronized, also Race-frei, auch wenn AboutDialog + // gleichzeitig aufgerufen wird. + Thread { + LicenseStatusProvider.ensureLoaded(applicationContext) + }.start() + } + + /** + * v2.4.0 — MapLibre Native Android SDK initialisieren (AKTIVER Renderer). + * + * Ab v2.4.0 rendert der Explorer mit MapLibre Native (siehe + * ExplorerScreenMapLibre.kt). Init ohne API-Key, ohne Telemetrie. Tiles + * werden direkt vom Endgerät geladen (OSM Standard bzw. ESRI World Imagery + * oder eigene Tile-URL für Satellit). + * + * Wenn die Initialisierung fehlschlägt (selten — fehlende native .so), + * bleibt der MapLibre-Renderer nicht nutzbar. Als interner Fallback ist + * der osmdroid-basierte [ui.ExplorerScreen] im Quelltext erhalten. + */ + private fun initMapLibre() { + runCatching { + org.maplibre.android.MapLibre.getInstance(this) + }.onFailure { e -> + android.util.Log.w("WaypointApp", "MapLibre init übersprungen: ${e.message}") + } } private fun createNotificationChannels() { diff --git a/app/src/main/kotlin/de/waypointaudio/data/AudioClip.kt b/app/src/main/kotlin/de/waypointaudio/data/AudioClip.kt new file mode 100644 index 0000000..eb369f9 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/AudioClip.kt @@ -0,0 +1,44 @@ +package de.waypointaudio.data + +import java.util.UUID + +/** + * v2.5.12 — Ein einzelner Audio-Clip eines Wegpunkts. + * + * Mehrere Clips pro Wegpunkt werden vom Tour-Play in der durch [orderIndex] + * vorgegebenen Reihenfolge nacheinander abgespielt; deaktivierte Clips werden + * dabei übersprungen. + * + * @param id Stabile UUID. Wird bei Persistenz/Backup/Import beibehalten. + * @param title Anzeigename des Clips (z. B. "Einleitung", "Kapitel 2"). + * Leer = Fallback auf [displayFileName] bzw. "Audio-Datei". + * @param uri Audio-URI (content:// oder file://). Leer = ungültiger Clip. + * @param displayFileName Lesbarer Dateiname für UI/Backup (z. B. der originale + * Dateiname aus dem Storage Access Framework). + * @param isActive Wenn false, wird der Clip beim Tour-Play übersprungen. + * Manuelles Einzel-Abspielen ist trotzdem möglich. + * @param isPrimary Markiert den "Haupt"-Clip eines Wegpunkts. Beim Migrieren + * legacy-Soundclips ist genau dieser Clip primary = true. + * Der primary-Clip wird in Listenansichten als Standard- + * Vorschau verwendet. + * @param orderIndex Stabile Sortierung. Niedrigere Werte werden zuerst + * abgespielt. Bei gleichen Werten entscheidet die UUID, + * damit die Reihenfolge deterministisch bleibt. + */ +data class AudioClip( + val id: String = UUID.randomUUID().toString(), + val title: String = "", + val uri: String = "", + val displayFileName: String = "", + val isActive: Boolean = true, + val isPrimary: Boolean = false, + val orderIndex: Int = 0 +) { + /** Effektive Anzeige: Titel → Dateiname → "Audio-Datei". */ + val effectiveTitle: String + get() = title.ifBlank { displayFileName.ifBlank { "Audio-Datei" } } + + /** True, wenn der Clip eine nicht-leere URI besitzt. */ + val hasAudio: Boolean + get() = uri.isNotBlank() +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt index e9f3fea..f5a01bf 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt @@ -2,14 +2,23 @@ package de.waypointaudio.data import android.content.Context import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map /** - * Globale Audio-Routing-Einstellungen für Live/PTT. + * Aufnahmeformat für PTT-Mitschnitt. + * M4A_AAC wird produktiv unterstützt; WAV ist in v2.1.0 absichtlich deaktiviert + * (siehe Handoff). Der enum-Wert bleibt vorhanden für künftige Erweiterung. + */ +enum class PttRecordingFormat { M4A_AAC, WAV } + +/** + * Globale Audio-Routing- und PTT-Einstellungen für Live/PTT. * * Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach * einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern. @@ -18,22 +27,32 @@ import kotlinx.coroutines.flow.map * * @param selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard * @param selectedOutputDeviceId ID des Ausgabegeräts, null = Systemstandard + * @param liveOutputEnabled Mikrofon-Loop auf Ausgabegerät (klassische PTT). + * Standard: true (rückwärtskompatibel zu v2.0.8). + * @param recordingEnabled PTT-Aufnahme aktiv. Standard: false. + * @param recordingFormat Aufnahmeformat. Standard: M4A_AAC. */ data class AudioRoutingSettings( val selectedInputDeviceId: Int? = null, - val selectedOutputDeviceId: Int? = null + val selectedOutputDeviceId: Int? = null, + val liveOutputEnabled: Boolean = true, + val recordingEnabled: Boolean = false, + val recordingFormat: PttRecordingFormat = PttRecordingFormat.M4A_AAC ) private val Context.audioRoutingDataStore by preferencesDataStore(name = "audio_routing_settings") /** - * Persistenz-Schicht für Audio-Routing-Einstellungen via DataStore. + * Persistenz-Schicht für Audio-Routing- und PTT-Einstellungen via DataStore. */ class AudioRoutingStore(private val context: Context) { private companion object { - val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id") - val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id") + val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id") + val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id") + val KEY_LIVE_OUTPUT = booleanPreferencesKey("ptt_live_output_enabled") + val KEY_RECORDING_ENABLED = booleanPreferencesKey("ptt_recording_enabled") + val KEY_RECORDING_FORMAT = stringPreferencesKey("ptt_recording_format") // Sentinel: -1 means "use system default" (null cannot be stored as Int) const val NO_DEVICE = -1 } @@ -42,14 +61,22 @@ class AudioRoutingStore(private val context: Context) { .map { prefs -> AudioRoutingSettings( selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }, - selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE } + selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }, + liveOutputEnabled = prefs[KEY_LIVE_OUTPUT] ?: true, + recordingEnabled = prefs[KEY_RECORDING_ENABLED] ?: false, + recordingFormat = prefs[KEY_RECORDING_FORMAT] + ?.let { runCatching { PttRecordingFormat.valueOf(it) }.getOrNull() } + ?: PttRecordingFormat.M4A_AAC ) } suspend fun save(settings: AudioRoutingSettings) { context.audioRoutingDataStore.edit { prefs -> - prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE - prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE + prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE + prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE + prefs[KEY_LIVE_OUTPUT] = settings.liveOutputEnabled + prefs[KEY_RECORDING_ENABLED] = settings.recordingEnabled + prefs[KEY_RECORDING_FORMAT] = settings.recordingFormat.name } } diff --git a/app/src/main/kotlin/de/waypointaudio/data/BackgroundReliabilityStore.kt b/app/src/main/kotlin/de/waypointaudio/data/BackgroundReliabilityStore.kt new file mode 100644 index 0000000..11dd611 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/BackgroundReliabilityStore.kt @@ -0,0 +1,37 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * v2.5.13 — Speichert, für welchen versionCode der Nutzer den + * "Hintergrundwiedergabe absichern"-Hinweis bereits weggeklickt hat. + * + * Damit nervt der Hinweis nicht erneut bei jedem Tour-Start, taucht aber + * automatisch bei jedem neuen Update (steigt der versionCode) wieder auf, + * weil sich Empfehlungen mit jeder Version ändern können. + */ +private val Context.backgroundReliabilityDataStore by preferencesDataStore( + name = "background_reliability_settings" +) + +class BackgroundReliabilityStore(private val context: Context) { + + private companion object { + val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code") + } + + /** Letzter ausgeblendeter versionCode; 0 = noch nie ausgeblendet. */ + val dismissedVersionCode: Flow = context.backgroundReliabilityDataStore.data + .map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L } + + suspend fun setDismissedVersionCode(code: Long) { + context.backgroundReliabilityDataStore.edit { prefs -> + prefs[KEY_DISMISSED_VERSION_CODE] = code + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/ExplorerSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/ExplorerSettings.kt new file mode 100644 index 0000000..b14ccf9 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/ExplorerSettings.kt @@ -0,0 +1,40 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Persistente Einstellungen des Explorers (v2.2.3). + * + * Aktuell nur „GPS-Nachführung an/aus". Die Einstellung überlebt + * Neustarts und Navigation; eine manuelle Kartenverschiebung darf + * den Wert nicht automatisch wieder auf „an" setzen. + */ +data class ExplorerSettings( + val followLocation: Boolean = true, +) + +private val Context.explorerDataStore by preferencesDataStore(name = "explorer_settings") + +class ExplorerSettingsStore(private val context: Context) { + + private companion object { + val KEY_FOLLOW = booleanPreferencesKey("follow_location") + } + + val settings: Flow = context.explorerDataStore.data.map { prefs -> + ExplorerSettings( + followLocation = prefs[KEY_FOLLOW] ?: true + ) + } + + suspend fun setFollowLocation(enabled: Boolean) { + context.explorerDataStore.edit { prefs -> + prefs[KEY_FOLLOW] = enabled + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/HomeModulesSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/HomeModulesSettings.kt new file mode 100644 index 0000000..143d921 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/HomeModulesSettings.kt @@ -0,0 +1,53 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * v2.5.0 — Sichtbarkeitsoptionen für die optionalen Module der neuen + * Audio-Bibliothek-Hauptseite. PTT- und Atmo-Modul lassen sich + * unabhängig ein- und ausblenden ("Startseite anpassen"). + * + * Default: beide Module sind sichtbar, damit bestehende Nutzer ihren + * bisherigen Zugang zu Live-PTT und Atmo nicht verlieren. + */ +data class HomeModulesSettings( + val showPtt: Boolean = true, + val showAtmo: Boolean = true, + val showMiniNowPlaying: Boolean = true +) + +private val Context.homeModulesDataStore by preferencesDataStore(name = "home_modules_settings") + +class HomeModulesSettingsStore(private val context: Context) { + + private companion object { + val KEY_SHOW_PTT = booleanPreferencesKey("show_ptt") + val KEY_SHOW_ATMO = booleanPreferencesKey("show_atmo") + val KEY_SHOW_MINI_NOW = booleanPreferencesKey("show_mini_now") + } + + val settings: Flow = context.homeModulesDataStore.data.map { prefs -> + HomeModulesSettings( + showPtt = prefs[KEY_SHOW_PTT] ?: true, + showAtmo = prefs[KEY_SHOW_ATMO] ?: true, + showMiniNowPlaying = prefs[KEY_SHOW_MINI_NOW] ?: true + ) + } + + suspend fun setShowPtt(value: Boolean) { + context.homeModulesDataStore.edit { it[KEY_SHOW_PTT] = value } + } + + suspend fun setShowAtmo(value: Boolean) { + context.homeModulesDataStore.edit { it[KEY_SHOW_ATMO] = value } + } + + suspend fun setShowMiniNowPlaying(value: Boolean) { + context.homeModulesDataStore.edit { it[KEY_SHOW_MINI_NOW] = value } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/MapProvider.kt b/app/src/main/kotlin/de/waypointaudio/data/MapProvider.kt new file mode 100644 index 0000000..19c0291 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/MapProvider.kt @@ -0,0 +1,143 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.osmdroid.tileprovider.tilesource.ITileSource +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.tileprovider.tilesource.XYTileSource +import org.osmdroid.util.MapTileIndex + +/** + * Verfügbare Karten-Anbieter (Phase 1 von v2.0.0). + * + * Alle Provider werden via [org.osmdroid.tileprovider.tilesource.XYTileSource] + * angebunden — kein Library-Wechsel nötig. Die OSM-Attribution + * (siehe `osm_attribution_short`) bleibt für sämtliche Provider sichtbar. + * + * Hinweise zu Provider-spezifischen Bedingungen: + * - **CartoDB** Tiles dürfen für nicht-kommerzielle Nutzung frei verwendet + * werden (Attribution `© OpenStreetMap & CartoDB`). + * - **OpenTopoMap** ist für nicht-kommerzielle Nutzung frei. + * - **ESRI** Tiles sind für persönliche, nicht-kommerzielle Nutzung + * via Attribution `© Esri` erlaubt. + * - **Stadia Maps** verlangt einen API-Key für produktive Nutzung; der Default + * funktioniert für Tests; im Zweifel CartoDB/OSM verwenden. + */ +enum class MapProvider( + val key: String, + val displayNameRes: Int +) { + OSM("osm", de.waypointaudio.R.string.map_provider_osm), + CARTO_POSITRON("carto_positron", de.waypointaudio.R.string.map_provider_carto_positron), + CARTO_DARK("carto_dark", de.waypointaudio.R.string.map_provider_carto_dark), + OPEN_TOPO("open_topo", de.waypointaudio.R.string.map_provider_topo), + ESRI_SATELLITE("esri_satellite", de.waypointaudio.R.string.map_provider_esri_satellite), + ESRI_HYBRID("esri_hybrid", de.waypointaudio.R.string.map_provider_esri_hybrid), + STADIA_ALIDADE("stadia_alidade", de.waypointaudio.R.string.map_provider_stadia_alidade), + STADIA_OUTDOORS("stadia_outdoors", de.waypointaudio.R.string.map_provider_stadia_outdoors); + + fun toTileSource(): ITileSource = when (this) { + OSM -> TileSourceFactory.MAPNIK + CARTO_POSITRON -> XYTileSource( + "CartoDB Positron", 1, 19, 256, ".png", + arrayOf( + "https://a.basemaps.cartocdn.com/light_all/", + "https://b.basemaps.cartocdn.com/light_all/", + "https://c.basemaps.cartocdn.com/light_all/" + ), + "© OpenStreetMap-Mitwirkende, © CARTO" + ) + CARTO_DARK -> XYTileSource( + "CartoDB Dark Matter", 1, 19, 256, ".png", + arrayOf( + "https://a.basemaps.cartocdn.com/dark_all/", + "https://b.basemaps.cartocdn.com/dark_all/", + "https://c.basemaps.cartocdn.com/dark_all/" + ), + "© OpenStreetMap-Mitwirkende, © CARTO" + ) + OPEN_TOPO -> XYTileSource( + "OpenTopoMap", 1, 17, 256, ".png", + arrayOf( + "https://a.tile.opentopomap.org/", + "https://b.tile.opentopomap.org/", + "https://c.tile.opentopomap.org/" + ), + "Karte: © OpenTopoMap (CC-BY-SA)" + ) + ESRI_SATELLITE -> object : XYTileSource( + "ESRI World Imagery", 1, 19, 256, "", + arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/"), + "Tiles © Esri" + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + + MapTileIndex.getZoom(pMapTileIndex) + "/" + + MapTileIndex.getY(pMapTileIndex) + "/" + + MapTileIndex.getX(pMapTileIndex) + } + } + ESRI_HYBRID -> object : XYTileSource( + "ESRI Hybrid Reference", 1, 19, 256, "", + arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/"), + "Tiles © Esri" + ) { + override fun getTileURLString(pMapTileIndex: Long): String { + return baseUrl + + MapTileIndex.getZoom(pMapTileIndex) + "/" + + MapTileIndex.getY(pMapTileIndex) + "/" + + MapTileIndex.getX(pMapTileIndex) + } + } + STADIA_ALIDADE -> XYTileSource( + "Stadia Alidade Smooth", 1, 20, 256, ".png", + arrayOf("https://tiles.stadiamaps.com/tiles/alidade_smooth/"), + "© Stadia Maps, © OpenStreetMap-Mitwirkende" + ) + STADIA_OUTDOORS -> XYTileSource( + "Stadia Outdoors", 1, 20, 256, ".png", + arrayOf("https://tiles.stadiamaps.com/tiles/outdoors/"), + "© Stadia Maps, © OpenStreetMap-Mitwirkende" + ) + } + + companion object { + val DEFAULT = OSM + + fun fromKey(key: String?): MapProvider = + values().firstOrNull { it.key == key } ?: DEFAULT + } +} + +private val Context.mapProviderDataStore by preferencesDataStore(name = "map_provider_settings") + +/** + * Persistiert den ausgewählten Karten-Provider app-weit. + * + * Hinweis: Die Auswahl ist bewusst app-weit (nicht pro Tour) gehalten – Touren + * verwalten bisher keine kartenspezifischen Einstellungen, und der Wechsel des + * Anbieters ist im Karten-Menü jederzeit erreichbar. + */ +class MapProviderStore(private val context: Context) { + + private companion object { + val KEY_PROVIDER = stringPreferencesKey("selected_map_provider") + } + + val provider: Flow = context.mapProviderDataStore.data.map { prefs -> + MapProvider.fromKey(prefs[KEY_PROVIDER]) + } + + suspend fun currentProvider(): MapProvider = provider.first() + + suspend fun setProvider(provider: MapProvider) { + context.mapProviderDataStore.edit { prefs -> + prefs[KEY_PROVIDER] = provider.key + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt new file mode 100644 index 0000000..3b9c4d9 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt @@ -0,0 +1,241 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Basis-Kartenstil — v2.5.38 Explorer Map Styles. + * + * Alle Stile nutzen OpenFreeMap (https://openfreemap.org) für Vektor-Tiles, + * kostenlos ohne API-Key, ohne eigenen Server. Attribution: © OpenFreeMap, + * © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende. + * + * MODERN_2D — Positron: heller, reduzierter Vektorstil. Ideal als + * neutraler Hintergrund für Wegpunkte/Routen. + * URL: https://tiles.openfreemap.org/styles/positron + * + * DARK — Dark Matter: dunkler Vektorstil. + * URL: https://tiles.openfreemap.org/styles/dark + * + * OUTDOOR — Fiord/Outdoor: Liberty-Vektorstil mit Outdoor-/Geländecharakter. + * URL: https://tiles.openfreemap.org/styles/liberty + * (Liberty ist der vollständigste, lebendigste OpenFreeMap-Style + * mit deutlicher Grün-/Geländeanmutung.) + * + * CITY_3D — 3D Stadt: Positron-Basis + MapLibre fill-extrusion für + * Gebäude-Layer (building). Wenn kein building-Layer in der + * Style-Source, automatischer 2D-Fallback ohne Crash. + * URL: https://tiles.openfreemap.org/styles/positron + * + programmatische fill-extrusion nachträglich per addLayer. + * + * CLASSIC — Klassisch: OSM-Raster-Tiles (tile.openstreetmap.org), + * raster-basierter Fallback. Kein Vektor. + * + * SATELLITE — Legacy aus v2.4.x. Wird auf MODERN_2D migriert. + * Satellit ist ab v2.5.37 nicht mehr im UI angeboten. + * TERRAIN_3D — Legacy aus v2.4.x. Wird auf CITY_3D migriert. + * TOPO — Legacy aus v2.4.x. Wird auf OUTDOOR migriert. + * PERSPECTIVE — Legacy aus v2.3.x. Wird auf CITY_3D migriert. + */ +enum class MapBaseStyle(val key: String) { + MODERN_2D("modern_2d"), + DARK("dark"), + OUTDOOR("outdoor"), + CITY_3D("city_3d"), + CLASSIC("classic"), + // Legacy — für Rückwärtskompatibilität gespeicherter Settings: + SATELLITE("satellite"), + TERRAIN_3D("terrain_3d"), + TOPO("topo"), + PERSPECTIVE("perspective"); + + companion object { + val DEFAULT = MODERN_2D + fun fromKey(key: String?): MapBaseStyle { + return when (key) { + "modern_2d" -> MODERN_2D + "dark" -> DARK + "outdoor" -> OUTDOOR + "city_3d" -> CITY_3D + "classic" -> CLASSIC + // Legacy migration: + "satellite" -> MODERN_2D + "terrain_3d" -> CITY_3D + "topo" -> OUTDOOR + "perspective"-> CITY_3D + else -> DEFAULT + } + } + } +} + +/** + * POI-Kategorie-Auswahl — v2.5.38. + * + * Jede Kategorie schaltet eine Gruppe von MapLibre-Symbol-Layern sichtbar/unsichtbar. + * Wenn der Style keinen passenden Layer enthält, passiert nichts (kein Crash). + * + * Kategorien: + * SIGHTS — Sehenswürdigkeiten / Tourismus (tourism, historic, amenity:place_of_worship) + * INFRASTRUCTURE — Infrastruktur (hospital, pharmacy, police, post, atm, bank, fuel) + * GASTRONOMY — Gastronomie (restaurant, cafe, fast_food, bar, pub, ice_cream) + * MOBILITY — Mobilität (bus_stop, train_station, parking, bicycle, car_sharing) + */ +enum class PoiCategory(val key: String) { + SIGHTS("sights"), + INFRASTRUCTURE("infrastructure"), + GASTRONOMY("gastronomy"), + MOBILITY("mobility"); + + companion object { + fun fromKey(key: String?): PoiCategory? = values().firstOrNull { it.key == key } + } +} + +/** + * Persistierte POI-Sichtbarkeit je Kategorie — v2.5.44. + * Default: Alle vier Hauptkategorien aktiv, damit der Nutzer sofort + * alle POI-Typen sieht (Fix: vorher nur Sehenswürdigkeiten sichtbar). + * Namen-Labels bleiben standardmäßig aus (Performance/Übersichtlichkeit). + */ +data class PoiLayerSettings( + val showSights: Boolean = true, + val showInfrastructure: Boolean = true, + val showGastronomy: Boolean = true, + val showMobility: Boolean = true, + // v2.5.42 — Namen-Labels auf der Karte standardmäßig deaktiviert + val showPoiNames: Boolean = false, +) + +/** + * Gespeicherte Karten-Stil-Einstellungen — v2.5.38. + * + * @param baseStyle gewählter Basis-Stil (wird persistent gespeichert) + * @param showAudioLength ob die visuelle Audio-/Trigger-Länge als + * Ring/Indikator je Wegpunkt eingeblendet wird. + * @param perspectiveTilt Kamera-Pitch für CITY_3D (Grad, 0..60). + * @param showAudioLines Clip-Längen als projizierte Liniensegmente. + * @param routingEndpoint optionaler OSRM-Routing-Endpoint. + * @param poiLayers POI-Kategorien-Sichtbarkeit (persistent). + * + * Legacy-Felder (customSatelliteUrl, customTopoUrl, customTerrainDemUrl) werden + * weiterhin gelesen, aber nicht mehr in der UI angeboten — für Rückwärts- + * kompatibilität mit gespeicherten Preferences aus v2.4.x. + */ +data class MapStyleSettings( + val baseStyle: MapBaseStyle = MapBaseStyle.DEFAULT, + val customSatelliteUrl: String = "", // legacy, wird ignoriert + val customTopoUrl: String = "", // legacy, wird ignoriert + val customTerrainDemUrl: String = "", // legacy, wird ignoriert + val showAudioLength: Boolean = true, + val perspectiveTilt: Int = 35, + val showAudioLines: Boolean = true, + val routingEndpoint: String = "", + val poiLayers: PoiLayerSettings = PoiLayerSettings(), +) + +private val Context.mapStyleDataStore by preferencesDataStore(name = "map_style_settings") + +class MapStyleStore(private val context: Context) { + + private companion object { + val KEY_BASE_STYLE = stringPreferencesKey("base_style") + val KEY_SAT_URL = stringPreferencesKey("custom_satellite_url") + val KEY_TOPO_URL = stringPreferencesKey("custom_topo_url") + val KEY_TERRAIN_DEM_URL = stringPreferencesKey("custom_terrain_dem_url") + val KEY_SHOW_AUDIO_LEN = booleanPreferencesKey("show_audio_length") + val KEY_PERSPECTIVE_TILT = stringPreferencesKey("perspective_tilt") + val KEY_SHOW_AUDIO_LINES = booleanPreferencesKey("show_audio_lines") + val KEY_ROUTING_ENDPOINT = stringPreferencesKey("routing_endpoint") + // v2.5.38 — POI-Layer-Persistenz + val KEY_POI_SIGHTS = booleanPreferencesKey("poi_sights") + val KEY_POI_INFRASTRUCTURE = booleanPreferencesKey("poi_infrastructure") + val KEY_POI_GASTRONOMY = booleanPreferencesKey("poi_gastronomy") + val KEY_POI_MOBILITY = booleanPreferencesKey("poi_mobility") + // v2.5.42 + val KEY_POI_SHOW_NAMES = booleanPreferencesKey("poi_show_names") + } + + val settings: Flow = context.mapStyleDataStore.data.map { prefs -> + MapStyleSettings( + baseStyle = MapBaseStyle.fromKey(prefs[KEY_BASE_STYLE]), + customSatelliteUrl = prefs[KEY_SAT_URL].orEmpty(), + customTopoUrl = prefs[KEY_TOPO_URL].orEmpty(), + customTerrainDemUrl = prefs[KEY_TERRAIN_DEM_URL].orEmpty(), + showAudioLength = prefs[KEY_SHOW_AUDIO_LEN] ?: true, + perspectiveTilt = prefs[KEY_PERSPECTIVE_TILT]?.toIntOrNull()?.coerceIn(0, 60) ?: 35, + showAudioLines = prefs[KEY_SHOW_AUDIO_LINES] ?: true, + routingEndpoint = prefs[KEY_ROUTING_ENDPOINT].orEmpty(), + poiLayers = PoiLayerSettings( + showSights = prefs[KEY_POI_SIGHTS] ?: true, + // v2.5.44: Default auf true geändert — Nutzer ohne gespeicherte + // Einstellung bekommen jetzt alle Kategorien aktiv + showInfrastructure = prefs[KEY_POI_INFRASTRUCTURE] ?: true, + showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true, + showMobility = prefs[KEY_POI_MOBILITY] ?: true, + showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false, + ) + ) + } + + suspend fun setBaseStyle(style: MapBaseStyle) { + context.mapStyleDataStore.edit { it[KEY_BASE_STYLE] = style.key } + } + + suspend fun setShowAudioLength(enabled: Boolean) { + context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LEN] = enabled } + } + + suspend fun setPerspectiveTilt(tilt: Int) { + context.mapStyleDataStore.edit { + it[KEY_PERSPECTIVE_TILT] = tilt.coerceIn(0, 60).toString() + } + } + + suspend fun setShowAudioLines(enabled: Boolean) { + context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LINES] = enabled } + } + + // v2.5.38 — POI-Layer-Persistenz + suspend fun setPoiLayers(poi: PoiLayerSettings) { + context.mapStyleDataStore.edit { + it[KEY_POI_SIGHTS] = poi.showSights + it[KEY_POI_INFRASTRUCTURE] = poi.showInfrastructure + it[KEY_POI_GASTRONOMY] = poi.showGastronomy + it[KEY_POI_MOBILITY] = poi.showMobility + it[KEY_POI_SHOW_NAMES] = poi.showPoiNames + } + } + + // 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), + // aber im UI nicht mehr angeboten und vom Style-Builder ignoriert. + suspend fun setCustomSatelliteUrl(url: String) { + context.mapStyleDataStore.edit { it[KEY_SAT_URL] = url.trim() } + } + suspend fun setCustomTopoUrl(url: String) { + context.mapStyleDataStore.edit { it[KEY_TOPO_URL] = url.trim() } + } + suspend fun setCustomTerrainDemUrl(url: String) { + context.mapStyleDataStore.edit { it[KEY_TERRAIN_DEM_URL] = url.trim() } + } + + /** + * Speichert den Routing-Endpunkt — v2.4.4: nutzt + * [de.waypointaudio.util.OsrmRoutingClient.normalizeBase], sodass der + * Nutzer sowohl die reine Basis-URL (`https://router.project-osrm.org`) + * als auch die vollständige `/route/v1/driving`-Variante speichern darf. + * Intern landet in den Preferences immer die normalisierte Basis-URL, + * was Doppelprefixe bei der eigentlichen Request-URL ausschließt. + */ + suspend fun setRoutingEndpoint(url: String) { + val normalized = de.waypointaudio.util.OsrmRoutingClient.normalizeBase(url) + context.mapStyleDataStore.edit { it[KEY_ROUTING_ENDPOINT] = normalized } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/PlayableItem.kt b/app/src/main/kotlin/de/waypointaudio/data/PlayableItem.kt new file mode 100644 index 0000000..8d1d1d5 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/PlayableItem.kt @@ -0,0 +1,86 @@ +package de.waypointaudio.data + +/** + * v2.5.14 — Flach gewickelter Playback-Eintrag. + * + * Ein [PlayableItem] beschreibt genau eine abspielbare Audio-Quelle in der + * Tour-Reihenfolge. Die Sequenz wird aus den Wegpunkten der aktuell sichtbaren + * Tour aufgebaut: pro Wegpunkt werden alle aktiven Clips in der durch + * [AudioClip.orderIndex] vorgegebenen Reihenfolge expandiert. Diese flache + * Liste ist die *einzige Wahrheit* für die manuelle Wiedergabe, die + * Vor/Zurück-Navigation im Clip-Detail-Player und die Anzeige "Clip 2 von 3". + * + * Wichtig: + * - [waypointIndex] und [waypointTotal] referenzieren die Position des + * Wegpunkts in der abspielbaren Tour-Liste (1-basiert, nicht der Index in + * der DataStore-Reihenfolge). + * - [clipIndex] und [clipTotal] referenzieren die Position des Clips + * innerhalb seines Wegpunkts (1-basiert). + * - [compoundId] = "${waypointId}#${clipId}" ist der ID-Schlüssel, den der + * [de.waypointaudio.service.ManualAudioPlayer] im Single-Mode verwendet. + * Damit erkennt der Player "schon spielend", wenn ein zweites Tippen auf + * denselben Clip in Pause/Resume münden soll. + */ +data class PlayableItem( + val waypointId: String, + val clipId: String, + val waypointIndex: Int, + val waypointTotal: Int, + val clipIndex: Int, + val clipTotal: Int, + val waypointName: String, + val clipTitle: String, + val uri: String, +) { + val compoundId: String + get() = "$waypointId#$clipId" + + /** + * Anzeigename für Notification/Mini-Player. Bei mehreren Clips wird das + * Suffix "(2/3)" angehängt, damit der Nutzer auch ohne offenes Detail- + * Sheet erkennt, an welcher Stelle er sich befindet. + */ + val displayTitle: String + get() { + val wp = waypointName.ifBlank { "Wegpunkt" } + val clip = clipTitle.takeIf { it.isNotBlank() } + return when { + clipTotal <= 1 -> wp + clip == null -> "$wp ($clipIndex/$clipTotal)" + else -> "$wp · $clip ($clipIndex/$clipTotal)" + } + } +} + +/** + * v2.5.14 — Erzeugt die flache Playback-Sequenz für eine Liste von Wegpunkten + * (typischerweise: alle aktiven, abspielbaren Wegpunkte der aktuellen Tour). + * + * Wegpunkte ohne aktive Clips werden übersprungen. Innerhalb eines Wegpunkts + * werden die Clips in der durch [Waypoint.activeClips] festgelegten Sortierung + * (orderIndex, dann id) expandiert. + */ +fun List.toPlayableSequence(): List { + val waypointsWithClips = this + .map { wp -> wp to wp.activeClips() } + .filter { it.second.isNotEmpty() } + val waypointTotal = waypointsWithClips.size + val items = mutableListOf() + waypointsWithClips.forEachIndexed { wpIdx, (wp, clips) -> + val clipTotal = clips.size + clips.forEachIndexed { clipIdx, clip -> + items += PlayableItem( + waypointId = wp.id, + clipId = clip.id, + waypointIndex = wpIdx + 1, + waypointTotal = waypointTotal, + clipIndex = clipIdx + 1, + clipTotal = clipTotal, + waypointName = wp.name, + clipTitle = clip.effectiveTitle, + uri = clip.uri, + ) + } + } + return items +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/PlaybackProgress.kt b/app/src/main/kotlin/de/waypointaudio/data/PlaybackProgress.kt new file mode 100644 index 0000000..ccc956a --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/PlaybackProgress.kt @@ -0,0 +1,37 @@ +package de.waypointaudio.data + +/** + * v2.5.14 — Schlanker Fortschritts-Snapshot des manuellen Players. + * + * Wird vom ViewModel periodisch (Tick ~500 ms) aktualisiert, solange Audio + * läuft, und vom Clip-Detail-Player sowie der kompakten Wiedergabeleiste + * konsumiert. + * + * @property positionMs Verstrichene Position in ms (>= 0). + * @property durationMs Gesamtdauer in ms; -1 wenn unbekannt (Streams, + * noch nicht geprepared). UI sollte dann "--:--" zeigen. + * @property isPlaying True, solange tatsächlich abgespielt wird (nicht + * pausiert/gestoppt). Bequemes Flag für die Hülle. + */ +data class PlaybackProgress( + val positionMs: Long = 0L, + val durationMs: Long = -1L, + val isPlaying: Boolean = false, +) { + /** Verbleibende Restzeit in ms, oder -1 wenn unbekannt. */ + val remainingMs: Long + get() = if (durationMs > 0) (durationMs - positionMs).coerceAtLeast(0L) else -1L + + companion object { + val EMPTY = PlaybackProgress() + } +} + +/** Formatiert eine Millisekundenzahl als "m:ss" oder "--:--" wenn unbekannt. */ +fun formatRemaining(remainingMs: Long): String { + if (remainingMs < 0) return "--:--" + val totalSeconds = (remainingMs + 500) / 1000 + val m = totalSeconds / 60 + val s = totalSeconds % 60 + return "%d:%02d".format(m, s) +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/PttRecording.kt b/app/src/main/kotlin/de/waypointaudio/data/PttRecording.kt new file mode 100644 index 0000000..7bd32db --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/PttRecording.kt @@ -0,0 +1,118 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.UUID + +/** + * Metadaten einer PTT-Aufnahme. + * + * Die Audiodatei selbst liegt in [filePath] (App-spezifisches Verzeichnis, + * Unterordner `recordings/`). [uri] ist die file://-Repräsentation, die für + * Wegpunkt-Zuweisung und Wiedergabe verwendet wird. + * + * @param id UUID der Aufnahme + * @param displayName Anzeigename (Datei ohne Erweiterung, vom Nutzer ggf. überschreibbar) + * @param filePath Absoluter Pfad zur Audiodatei + * @param uri file://-URI für Wiedergabe und Wegpunkt-Zuweisung + * @param format Dateiformat ("m4a", "wav") + * @param createdAt Erstellzeitpunkt in Unix-Millisekunden + * @param durationMs Aufnahmedauer in Millisekunden (0 wenn unbekannt) + * @param latitude Optionaler Breitengrad der Aufnahmestelle + * @param longitude Optionaler Längengrad der Aufnahmestelle + */ +data class PttRecording( + val id: String = UUID.randomUUID().toString(), + val displayName: String, + val filePath: String, + val uri: String, + val format: String, + val createdAt: Long, + val durationMs: Long = 0L, + val latitude: Double? = null, + val longitude: Double? = null +) + +private val Context.pttRecordingsDataStore by preferencesDataStore(name = "ptt_recordings") + +/** + * Persistente Bibliothek aller PTT-Aufnahmen (Metadaten). + * + * Die Audiodateien liegen im app-spezifischen Verzeichnis + * `/recordings/`. Dieser Store hält ausschließlich die Metadaten. + */ +class PttRecordingStore(private val context: Context) { + + private companion object { + val KEY_ENTRIES = stringPreferencesKey("recordings_json") + } + + private val gson = Gson() + private val listType = object : TypeToken>() {}.type + + /** Reaktiver Flow aller Aufnahme-Metadaten (neueste zuerst, sortiert nach createdAt desc). */ + val recordings: Flow> = context.pttRecordingsDataStore.data + .map { prefs -> + val raw = prefs[KEY_ENTRIES] + if (raw.isNullOrBlank()) emptyList() + else runCatching { + gson.fromJson>(raw, listType) ?: emptyList() + }.getOrElse { emptyList() } + } + .map { list -> list.sortedByDescending { it.createdAt } } + + /** Liefert die aktuelle Liste einmalig (für innerhalb-Transaktionen). */ + private suspend fun readOnce(): List { + var result: List = emptyList() + context.pttRecordingsDataStore.edit { prefs -> + val raw = prefs[KEY_ENTRIES] + result = if (raw.isNullOrBlank()) emptyList() + else runCatching { + gson.fromJson>(raw, listType) ?: emptyList() + }.getOrElse { emptyList() } + } + return result + } + + suspend fun add(entry: PttRecording) { + context.pttRecordingsDataStore.edit { prefs -> + val raw = prefs[KEY_ENTRIES] + val list = if (raw.isNullOrBlank()) emptyList() + else runCatching { + gson.fromJson>(raw, listType) ?: emptyList() + }.getOrElse { emptyList() } + val updated = (list + entry).distinctBy { it.id } + prefs[KEY_ENTRIES] = gson.toJson(updated) + } + } + + suspend fun delete(id: String) { + context.pttRecordingsDataStore.edit { prefs -> + val raw = prefs[KEY_ENTRIES] + val list = if (raw.isNullOrBlank()) emptyList() + else runCatching { + gson.fromJson>(raw, listType) ?: emptyList() + }.getOrElse { emptyList() } + prefs[KEY_ENTRIES] = gson.toJson(list.filterNot { it.id == id }) + } + } + + suspend fun rename(id: String, newName: String) { + context.pttRecordingsDataStore.edit { prefs -> + val raw = prefs[KEY_ENTRIES] + val list = if (raw.isNullOrBlank()) emptyList() + else runCatching { + gson.fromJson>(raw, listType) ?: emptyList() + }.getOrElse { emptyList() } + prefs[KEY_ENTRIES] = gson.toJson(list.map { + if (it.id == id) it.copy(displayName = newName) else it + }) + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/TermsStore.kt b/app/src/main/kotlin/de/waypointaudio/data/TermsStore.kt new file mode 100644 index 0000000..2819080 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TermsStore.kt @@ -0,0 +1,41 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * v2.5.9 — Persistiert die vom Nutzer akzeptierte Version der + * Nutzungsbedingungen. Wird beim ersten App-Start abgefragt; eine + * neue Terms-Version (z. B. „2.5.11-terms-1") führt zur erneuten + * Anzeige des Zustimmungsdialogs. + */ +private val Context.termsDataStore by preferencesDataStore(name = "terms_settings") + +object TermsVersions { + /** + * Aktuell gültige Terms-Version. Beim Bump dieser Konstante muss + * der In-App-Dialog beim nächsten Start neu zustimmen lassen. + */ + const val CURRENT = "2.5.11-terms-1" +} + +class TermsStore(private val context: Context) { + + private companion object { + val KEY_ACCEPTED_VERSION = stringPreferencesKey("accepted_terms_version") + } + + /** Zuletzt akzeptierte Terms-Version (Leerstring = noch nie akzeptiert). */ + val acceptedVersion: Flow = context.termsDataStore.data + .map { it[KEY_ACCEPTED_VERSION] ?: "" } + + suspend fun setAccepted(version: String) { + context.termsDataStore.edit { prefs -> + prefs[KEY_ACCEPTED_VERSION] = version + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt index 112cb07..fb569d7 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt @@ -33,13 +33,27 @@ data class PlaylistItem( val displayName: String ) +/** + * Ein einzelner Stream-Eintrag (z. B. Internetradio-Sender) in der Stream-Playlist + * einer Tour. + * + * @param url HTTP/HTTPS-Direkt-URL zum Stream. + * @param name Optionaler Anzeigename. Wenn leer, wird die URL/der Host angezeigt. + */ +data class StreamEntry( + val url: String, + val name: String = "" +) + /** * Begleitmusik-Einstellungen für eine Tour. * * @param enabled Ob Begleitmusik für diese Tour aktiv ist. * @param sourceType Quelle: lokale Playlist oder Stream-URL. * @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher. - * @param streamUrl Direkte HTTP/HTTPS-Stream-URL (z. B. Internetradio). + * @param streamPlaylist Liste von Stream-Einträgen (ab v2.0.8 – Stream-Playlist statt einzelner URL). + * @param streamUrl Legacy-Feld für einzelne Stream-URL (Backward-Compat – bei Bedarf in + * streamPlaylist migriert; siehe [migrated]). * @param behavior Verhalten wenn Wegpunkt-Audio abgespielt wird. * @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden. * @param duckVolume Lautstärke (0.0–1.0) während des Duck-Modus. @@ -51,10 +65,37 @@ data class TourAudioSettings( val enabled: Boolean = false, val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST, val localPlaylist: List = emptyList(), + val streamPlaylist: List = emptyList(), val streamUrl: String? = null, val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME, val fadeDurationMs: Long = 1500L, val duckVolume: Float = 0.25f, val shuffle: Boolean = false, val autoStartAfterWaypoint: Boolean = false -) +) { + /** + * Liefert die effektive Stream-Playlist – wenn keine streamPlaylist gespeichert ist, + * aber das Legacy-streamUrl gesetzt ist, wird daraus ein einzelner Eintrag erzeugt. + */ + val effectiveStreamPlaylist: List + get() = when { + streamPlaylist.isNotEmpty() -> streamPlaylist + !streamUrl.isNullOrBlank() -> listOf(StreamEntry(url = streamUrl, name = "")) + else -> emptyList() + } + + /** + * Erzeugt eine Kopie mit migrierter Stream-Playlist: + * falls streamPlaylist leer ist und streamUrl gesetzt war, wird streamUrl in + * die Stream-Playlist überführt und das Legacy-Feld geleert. + */ + fun migrated(): TourAudioSettings { + if (streamPlaylist.isEmpty() && !streamUrl.isNullOrBlank()) { + return copy( + streamPlaylist = listOf(StreamEntry(url = streamUrl, name = "")), + streamUrl = null + ) + } + return this + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourCover.kt b/app/src/main/kotlin/de/waypointaudio/data/TourCover.kt new file mode 100644 index 0000000..6093eba --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TourCover.kt @@ -0,0 +1,68 @@ +package de.waypointaudio.data + +/** + * v2.5.5 — Persistierbares Tour-Cover. + * + * Statt eines externen Bild-Pickers wählt der Nutzer aus einem festen, + * deterministischen Designkatalog (Icon + Farbverlauf). Das hält den + * Datenpfad stabil, vermeidet Berechtigungs-/Lifecycle-Themen und lässt + * sich verlustfrei via JSON/Backup transportieren. + * + * Persistenz: + * - JSON-Map `tourName → TourCover` im [WaypointStore] unter eigenem Key. + * - Bestehende Backups ohne diesen Key bleiben gültig (Default leer). + * - Backup-Export/Import nehmen die Map mit (siehe BackupExportManager). + * + * UI: + * - Wenn für eine Tour kein Cover gewählt ist, behält die Hero-Karte ihre + * bisherige deterministische Optik (Akzentfarbe + Icon-Heuristik aus dem + * Tournamen). „Cover ändern…“ ersetzt das durch die Auswahl. + */ +enum class TourCoverIcon { + HEADPHONES, + MUSIC_NOTE, + HIKING, + PARK, + CITY, + BIKE, + WAVES, + EXPLORE, + LANDSCAPE, + CAMPAIGN, + NIGHTLIFE, + THEATER_COMEDY, +} + +/** + * Auswählbare Farbschemen für das Cover. Werte sind Identifier; die konkrete + * Farbabbildung erfolgt in der UI (siehe TourCoverPalette in WaypointListScreen), + * damit Theme-Anpassungen ohne Daten-Migration möglich bleiben. + */ +enum class TourCoverPalette { + TEAL, + OCEAN, + SUNSET, + FOREST, + BERRY, + DUSK, + SAND, + MIDNIGHT, +} + +/** + * Vollständiges, persistiertes Cover einer Tour. + * + * Bewusst minimal: nur Icon + Palette. Texte/Beschriftungen bleiben aus dem + * Tournamen abgeleitet. Damit ist die Auswahl klein, robust und schnell. + */ +data class TourCover( + val icon: TourCoverIcon = TourCoverIcon.HEADPHONES, + val palette: TourCoverPalette = TourCoverPalette.TEAL, + /** + * v2.5.7 — Optionaler Pfad zu einem app-internen Cover-Bild. Liegt ein Bild + * vor, dominiert es Hero/Card; sonst wird Icon+Palette gerendert. Wird beim + * Speichern auf eine app-interne Datei kopiert (siehe TourCoverImageStore). + * Null/leer = kein Bild gewählt → Fallback auf Icon+Palette. + */ + val imageUri: String? = null, +) diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt b/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt index b055f85..7350990 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt @@ -34,7 +34,7 @@ class TourMusicStore(private val context: Context) { val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}" runCatching { val raw: Map? = gson.fromJson(json, mapType) - raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap() + raw?.mapValues { (_, v) -> v.toDomain().migrated() } ?: emptyMap() }.getOrDefault(emptyMap()) } @@ -81,10 +81,21 @@ internal data class PlaylistItemJson( } } +internal data class StreamEntryJson( + val url: String = "", + val name: String = "" +) { + fun toDomain() = StreamEntry(url = url, name = name) + companion object { + fun fromDomain(entry: StreamEntry) = StreamEntryJson(entry.url, entry.name) + } +} + internal data class TourAudioSettingsJson( val enabled: Boolean = false, val sourceType: String = "LOCAL_PLAYLIST", val localPlaylist: List = emptyList(), + val streamPlaylist: List? = null, val streamUrl: String? = null, val behavior: String = "PAUSE_RESUME", val fadeDurationMs: Long = 1500L, @@ -97,6 +108,7 @@ internal data class TourAudioSettingsJson( sourceType = runCatching { MusicSourceType.valueOf(sourceType) } .getOrDefault(MusicSourceType.LOCAL_PLAYLIST), localPlaylist = localPlaylist.map { it.toDomain() }, + streamPlaylist = streamPlaylist?.map { it.toDomain() } ?: emptyList(), streamUrl = streamUrl, behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) } .getOrDefault(WaypointMusicBehavior.PAUSE_RESUME), @@ -111,6 +123,7 @@ internal data class TourAudioSettingsJson( enabled = s.enabled, sourceType = s.sourceType.name, localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) }, + streamPlaylist = s.streamPlaylist.map { StreamEntryJson.fromDomain(it) }, streamUrl = s.streamUrl, behavior = s.behavior.name, fadeDurationMs = s.fadeDurationMs, diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourRoute.kt b/app/src/main/kotlin/de/waypointaudio/data/TourRoute.kt new file mode 100644 index 0000000..7eb38e4 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TourRoute.kt @@ -0,0 +1,94 @@ +package de.waypointaudio.data + +import java.util.UUID + +/** + * v2.4.6 — Tourroute (an die Tour gebunden, nicht an einen einzelnen Wegpunkt). + * + * Architekturentscheidung (Nutzerabstimmung): + * - Eine [Tour] kann dauerhaft eine zuvor geplante/berechnete Route besitzen. + * - Wegpunkte bleiben ein wiederverwendbarer Baustein – sie tragen keine + * Route. Dadurch lassen sich Wegpunkte später in andere Touren kopieren, + * ohne dass dort eine fremde Route mitgezogen wird. + * - Die [polyline] ist die führende Geometrie. Clipabschnitte und + * Längenlinien projizieren sich darauf — siehe Priorität in + * [de.waypointaudio.ui.ExplorerScreenMapLibre]. + * + * Persistenz: + * - Gespeichert im [WaypointStore] als JSON-Map `tourName → TourRoute`. + * - Bestehende Backups/JSON ohne dieses Feld bleiben gültig (Default leer). + * - Backup-Export/Import nehmen Tourrouten mit (siehe BackupExportManager). + */ +enum class RouteSourceKind { + /** Route stammt aus einer OSRM-Berechnung. */ + OSRM, + /** Reine Planlinie (Luftlinie zwischen den Stops). */ + PLANLINE, + /** Aus einem GPS-Track / Draft übernommen (zukünftig). */ + TRACK, + /** Aus einem importierten Backup übernommen. */ + IMPORTED, + /** + * v2.5.3 — Manuell auf der Karte gezeichnete Route ("Route zeichnen"). + * Die Polyline entsteht direkt durch Antippen der Karte, ohne OSRM-Aufruf. + * Inhaltlich gleich zu OSRM/PLANLINE (führende Geometrie ist [TourRoute.polyline]), + * aber als getrennte Quelle markiert, damit die UI klar zwischen + * "berechnet" und "gezeichnet" unterscheiden kann. + */ + MANUAL, +} + +/** + * Ein Wegpunkt der Route — Start, Via oder Ziel — mit Label und Quellhinweis. + * Bewusst eigene Struktur (nicht [Waypoint]), damit Tourrouten unabhängig + * von der globalen Wegpunktliste persistieren. + */ +data class TourRouteStop( + val latitude: Double, + val longitude: Double, + val label: String = "", + val sourceHint: String = "", +) + +/** Ein Lat/Lon-Punkt der Geometrie. */ +data class TourRoutePoint( + val lat: Double, + val lon: Double, +) + +/** + * Vollständige Tourroute. + * + * @param id Stabile ID (UUID). + * @param start Startpunkt (optional, kann bei reinen Geometrie-Importen fehlen). + * @param destination Zielpunkt (optional, siehe oben). + * @param via Geordnete Liste der Via-Stopps. + * @param polyline Routengeometrie als Lat/Lon-Punkte (führende Quelle). + * @param profile Routingprofil (z. B. "driving"). Leer = unspezifiziert. + * @param source Quellkategorie (OSRM/Planline/Track/Imported). + * @param endpoint Routing-Endpunkt oder Quellbeschreibung (Backend-URL, „Planlinie", …). + * @param distanceMeters Distanz in Metern (null wenn unbekannt, z. B. Planline). + * @param durationSeconds Dauer in Sekunden (null wenn unbekannt). + * @param createdAtMillis Anlegezeitpunkt (Unix ms). + * @param updatedAtMillis Letzte Aktualisierung (Unix ms). + */ +data class TourRoute( + val id: String = UUID.randomUUID().toString(), + val start: TourRouteStop? = null, + val destination: TourRouteStop? = null, + val via: List = emptyList(), + val polyline: List = emptyList(), + val profile: String = "driving", + val source: RouteSourceKind = RouteSourceKind.OSRM, + val endpoint: String = "", + val distanceMeters: Double? = null, + val durationSeconds: Double? = null, + val createdAtMillis: Long = System.currentTimeMillis(), + val updatedAtMillis: Long = System.currentTimeMillis(), +) { + /** Anzahl gespeicherter Geometriepunkte. Nützlich für Status/UI. */ + val pointCount: Int get() = polyline.size + + /** True wenn die Geometrie als Linie nutzbar ist. */ + val hasGeometry: Boolean get() = polyline.size >= 2 +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/TrackDraft.kt b/app/src/main/kotlin/de/waypointaudio/data/TrackDraft.kt new file mode 100644 index 0000000..94c1220 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TrackDraft.kt @@ -0,0 +1,38 @@ +package de.waypointaudio.data + +import java.util.UUID + +/** + * Ein eigenständig archivierter Track-Entwurf (Draft) — entsteht durch + * eine GPS-Aufzeichnung und kann später unabhängig weiterer Aufzeichnungen + * editiert und in eine Tour als Wegpunkte importiert werden. + * + * Wichtig: Jeder Draft hat eine eigene UUID. Neue Aufzeichnungen erzeugen + * NEUE Drafts und überschreiben keine vorhandenen. + * + * @param id Eindeutige ID + * @param name Anzeigename (vorgegeben aus Tour + Datum, vom Nutzer änderbar) + * @param sourceTourName Name der Tour, in der die Aufzeichnung lief + * @param createdAt Unix-Millisekunden des Anlegens + * @param updatedAt Unix-Millisekunden der letzten Bearbeitung + * @param points Geordnete Liste der Trackpunkte (vollständig) + * @param markedIndexes Indizes (in [points]) der explizit als künftiger + * Wegpunkt markierten Punkte. Leer = nichts markiert. + * @param notes Optionale Notiz des Nutzers + */ +data class TrackDraft( + val id: String = UUID.randomUUID().toString(), + val name: String = "", + val sourceTourName: String = Waypoint.DEFAULT_TOUR_NAME, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val points: List = emptyList(), + val markedIndexes: List = emptyList(), + val notes: String = "" +) { + /** Punktanzahl. */ + val pointCount: Int get() = points.size + + /** Anzahl markierter (für Import vorgesehener) Punkte. */ + val markedCount: Int get() = markedIndexes.size +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/VersionNoticeStore.kt b/app/src/main/kotlin/de/waypointaudio/data/VersionNoticeStore.kt new file mode 100644 index 0000000..65ffcec --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/VersionNoticeStore.kt @@ -0,0 +1,32 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Speichert den letzten versionCode, für den der Versionshinweis-Dialog + * vom Nutzer ausgeblendet wurde. Steigt der versionCode, erscheint der + * Dialog automatisch erneut – auch wenn er zuvor deaktiviert wurde. + */ +private val Context.versionNoticeDataStore by preferencesDataStore(name = "version_notice_settings") + +class VersionNoticeStore(private val context: Context) { + + private companion object { + val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code") + } + + /** Letzter ausgeblendeter versionCode, oder 0 falls noch nie ausgeblendet. */ + val dismissedVersionCode: Flow = context.versionNoticeDataStore.data + .map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L } + + suspend fun setDismissedVersionCode(code: Long) { + context.versionNoticeDataStore.edit { prefs -> + prefs[KEY_DISMISSED_VERSION_CODE] = code + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/WakeLockSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/WakeLockSettings.kt new file mode 100644 index 0000000..c273644 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/WakeLockSettings.kt @@ -0,0 +1,64 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * Modus, wann das Display aktiv gehalten werden soll. + */ +enum class WakeLockMode(val storageKey: String, val germanLabel: String) { + NEVER("never", "Nie"), + APP_FOREGROUND("foreground", "Immer wenn App im Vordergrund"), + ACTIVE_TOUR("active_tour", "Nur bei aktiver Tour/GPS"); + + companion object { + fun fromKey(key: String?): WakeLockMode = when (key) { + APP_FOREGROUND.storageKey -> APP_FOREGROUND + ACTIVE_TOUR.storageKey -> ACTIVE_TOUR + else -> NEVER + } + } +} + +data class WakeLockSettings( + val mode: WakeLockMode = WakeLockMode.NEVER, + /** True, sobald der Akku-Hinweis dem Nutzer einmal gezeigt wurde. */ + val batteryHintShown: Boolean = false +) + +private val Context.wakeLockDataStore by preferencesDataStore(name = "wake_lock_settings") + +class WakeLockSettingsStore(private val context: Context) { + + private companion object { + val KEY_MODE = stringPreferencesKey("wake_lock_mode") + val KEY_BATTERY_HINT_SHOWN = booleanPreferencesKey("battery_hint_shown") + } + + val settings: Flow = context.wakeLockDataStore.data.map { prefs -> + WakeLockSettings( + mode = WakeLockMode.fromKey(prefs[KEY_MODE]), + batteryHintShown = prefs[KEY_BATTERY_HINT_SHOWN] == true + ) + } + + suspend fun currentSettings(): WakeLockSettings = settings.first() + + suspend fun setMode(mode: WakeLockMode) { + context.wakeLockDataStore.edit { prefs -> + prefs[KEY_MODE] = mode.storageKey + } + } + + suspend fun markBatteryHintShown() { + context.wakeLockDataStore.edit { prefs -> + prefs[KEY_BATTERY_HINT_SHOWN] = true + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt b/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt index eb97c19..46c2784 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt @@ -23,11 +23,15 @@ enum class PlaybackMode { * @param latitude Breitengrad in Dezimalgrad * @param longitude Längengrad in Dezimalgrad * @param radiusMeters Erkennungsradius in Metern - * @param soundUri URI zur Audiodatei (content:// oder file://) - * @param soundName Anzeigename der Audiodatei + * @param soundUri Legacy-Single-Clip-URI (v2.5.11 und älter). In v2.5.12 + * ist [audioClips] die führende Repräsentation; das + * Feld bleibt zur Rückwärtskompatibilität gefüllt + * (typischerweise = primary-Clip-URI), sodass alte + * Lese-Pfade unverändert funktionieren. + * @param soundName Legacy-Dateiname (v2.5.11 und älter). Wie [soundUri] + * aus Kompatibilitätsgründen weiter gepflegt. * @param isActive Ob der Wegpunkt aktiv überwacht wird - * @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel: - * bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard". + * @param tourName Name der Tour/Route (Standard: "Standard"). * * --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) --- * @@ -39,6 +43,14 @@ enum class PlaybackMode { * @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist * @param allowedStartMinutes Tagesminute (0–1439), ab der das tägliche Zeitfenster beginnt * @param allowedEndMinutes Tagesminute (0–1439), bis zu der das tägliche Zeitfenster endet + * + * --- v2.5.12 Multi-Clip --- + * + * @param audioClips Liste der Audio-Clips dieses Wegpunkts. Leer = + * Migration aus Legacy-Feldern [soundUri]/[soundName] + * via [effectiveClips]. Aktive Clips werden in + * Reihenfolge ([AudioClip.orderIndex]) abgespielt; + * inaktive werden übersprungen. */ data class Waypoint( val id: String = UUID.randomUUID().toString(), @@ -61,10 +73,118 @@ data class Waypoint( val scheduleStartMillis: Long? = null, val scheduleEndMillis: Long? = null, val allowedStartMinutes: Int? = null, - val allowedEndMinutes: Int? = null + val allowedEndMinutes: Int? = null, + + // v2.5.12 — Multi-Clip-Erweiterung. Default = leer, damit alte JSONs ohne + // diesen Key weiterhin laden (Gson füllt den Default ein). + val audioClips: List = emptyList() ) { companion object { /** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */ const val DEFAULT_TOUR_NAME = "Standard" } } + +/** + * v2.5.12 — Liefert die *effektiv anzuwendende* Cliplist für diesen Wegpunkt. + * + * Verhalten: + * - Ist [Waypoint.audioClips] nicht leer, wird diese Liste in stabiler + * Sortierung (orderIndex, dann id) zurückgegeben. + * - Andernfalls wird – sofern [Waypoint.soundUri] vorhanden ist – ein + * synthetischer primary-Clip aus [Waypoint.soundUri]/[Waypoint.soundName] + * erzeugt. Damit funktionieren alle alten Wegpunkte ohne Migration + * transparent weiter. + * - Hat ein Wegpunkt weder [Waypoint.audioClips] noch [Waypoint.soundUri], + * ist die Rückgabe leer. + * + * Die ID des synthetischen Legacy-Clips wird deterministisch aus der Waypoint- + * ID abgeleitet, damit sich Skip-/UI-Zustände über mehrere Aufrufe hinweg + * stabil referenzieren lassen. + */ +fun Waypoint.effectiveClips(): List { + if (audioClips.isNotEmpty()) { + return audioClips.sortedWith( + compareBy({ it.orderIndex }, { it.id }) + ) + } + if (soundUri.isBlank()) return emptyList() + return listOf( + AudioClip( + id = "legacy-$id", + title = soundName, + uri = soundUri, + displayFileName = soundName, + isActive = true, + isPrimary = true, + orderIndex = 0 + ) + ) +} + +/** + * Alle aktiven, abspielbaren Clips (für Tour-Play). Reihenfolge wie + * [effectiveClips]; ohne deaktivierte und ohne leere URIs. + */ +fun Waypoint.activeClips(): List = + effectiveClips().filter { it.isActive && it.hasAudio } + +/** + * Primärer Clip (Vorschau, Listenansicht, Legacy-Fallback). + * + * Priorität: + * 1. Erster Clip in [effectiveClips] mit isPrimary = true (sofern abspielbar). + * 2. Erster *aktiver* Clip mit URI. + * 3. Erster Clip mit URI überhaupt. + * 4. null, falls kein Audio existiert. + */ +fun Waypoint.primaryClip(): AudioClip? { + val clips = effectiveClips() + return clips.firstOrNull { it.isPrimary && it.hasAudio } + ?: clips.firstOrNull { it.isActive && it.hasAudio } + ?: clips.firstOrNull { it.hasAudio } +} + +/** + * URI des primären Clips, oder Legacy-[Waypoint.soundUri] als Fallback. + * Praktisch für alle Stellen, die früher direkt `waypoint.soundUri` lasen. + */ +fun Waypoint.primarySoundUri(): String = + primaryClip()?.uri?.ifBlank { soundUri } ?: soundUri + +/** + * Anzeigename des primären Clips, oder Legacy-[Waypoint.soundName]. + */ +fun Waypoint.primarySoundName(): String = + primaryClip()?.effectiveTitle?.ifBlank { soundName } ?: soundName + +/** + * v2.5.12 — Hilfsfunktion zum Setzen der Clipliste mit konsistenten + * Legacy-Spiegel-Feldern. Die Felder [Waypoint.soundUri] und + * [Waypoint.soundName] werden auf den primären Clip gesetzt, sodass + * - alte Lese-Pfade weiter den Hauptclip sehen, + * - JSON-Exports (legacy-Format) ein sinnvolles soundUri-Feld enthalten, + * - der Tour-Play (multi-clip-aware) trotzdem alle Clips kennt. + * + * orderIndex wird beim Speichern auf die Listen-Position normalisiert, + * damit die Reihenfolge stabil aus der UI übernommen werden kann. + */ +fun Waypoint.withClips(newClips: List): Waypoint { + if (newClips.isEmpty()) { + return copy(audioClips = emptyList()) + } + val normalized = newClips.mapIndexed { idx, c -> c.copy(orderIndex = idx) } + val primary = normalized.firstOrNull { it.isPrimary && it.hasAudio } + ?: normalized.firstOrNull { it.isActive && it.hasAudio } + ?: normalized.firstOrNull { it.hasAudio } + ?: normalized.first() + // Genau einen Clip als primary markieren (idempotent) + val finalized = normalized.map { c -> + c.copy(isPrimary = (c.id == primary.id)) + } + return copy( + audioClips = finalized, + soundUri = primary.uri, + soundName = primary.effectiveTitle + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/WaypointListViewSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/WaypointListViewSettings.kt new file mode 100644 index 0000000..ace3464 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/WaypointListViewSettings.kt @@ -0,0 +1,57 @@ +package de.waypointaudio.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * v2.5.4 — Sichtbarkeitsoptionen für die optionalen Tour-Werkzeuge der + * klassischen Tour-/Waypoint-Ansicht ([WaypointListScreen]). Atmo, PTT + * und Tour-Zähler lassen sich unabhängig voneinander ein- und ausblenden + * ("Ansicht anpassen"), damit die Tour-/Wegpunkt-Inhalte visuell im + * Vordergrund stehen. + * + * Default: alle drei Module sind sichtbar, damit bestehende Nutzer ihre + * gewohnten Bedienelemente nicht verlieren. Persistenz erfolgt über einen + * eigenen DataStore – unabhängig von der bereits existierenden + * [HomeModulesSettings]-Logik der Startseite. + */ +data class WaypointListViewSettings( + val showPtt: Boolean = true, + val showAtmo: Boolean = true, + val showTourCounter: Boolean = true +) + +private val Context.waypointListViewDataStore by preferencesDataStore(name = "waypoint_list_view_settings") + +class WaypointListViewSettingsStore(private val context: Context) { + + private companion object { + val KEY_SHOW_PTT = booleanPreferencesKey("wl_show_ptt") + val KEY_SHOW_ATMO = booleanPreferencesKey("wl_show_atmo") + val KEY_SHOW_TOUR_COUNTER = booleanPreferencesKey("wl_show_tour_counter") + } + + val settings: Flow = context.waypointListViewDataStore.data.map { prefs -> + WaypointListViewSettings( + showPtt = prefs[KEY_SHOW_PTT] ?: true, + showAtmo = prefs[KEY_SHOW_ATMO] ?: true, + showTourCounter = prefs[KEY_SHOW_TOUR_COUNTER] ?: true + ) + } + + suspend fun setShowPtt(value: Boolean) { + context.waypointListViewDataStore.edit { it[KEY_SHOW_PTT] = value } + } + + suspend fun setShowAtmo(value: Boolean) { + context.waypointListViewDataStore.edit { it[KEY_SHOW_ATMO] = value } + } + + suspend fun setShowTourCounter(value: Boolean) { + context.waypointListViewDataStore.edit { it[KEY_SHOW_TOUR_COUNTER] = value } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt index 6283aae..9277416 100644 --- a/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt +++ b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt @@ -38,8 +38,21 @@ class WaypointStore(private val context: Context) { private val KEY_TOURS = stringPreferencesKey("tours_json") private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json") private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json") + private val KEY_TRACK_DRAFTS = stringPreferencesKey("track_drafts_json") + // v2.4.6 — persistente Tourrouten (tourName → TourRoute, JSON). + // Eigene Schlüssel-Datei: bestehende Backups/JSON ohne diesen Key + // bleiben gültig (Default = leere Map). + private val KEY_TOUR_ROUTES = stringPreferencesKey("tour_routes_json") + // v2.5.5 — Persistente Tour-Cover (tourName → TourCover, JSON). + // Optional pro Tour; fehlt der Eintrag, bleibt die deterministische + // Standard-Optik (Akzentfarbe + Icon-Heuristik aus Tournamen) erhalten. + private val KEY_TOUR_COVERS = stringPreferencesKey("tour_covers_json") } + private val draftListType = object : TypeToken>() {}.type + private val tourRoutesMapType = object : TypeToken>() {}.type + private val tourCoversMapType = object : TypeToken>() {}.type + /** Liefert einen Flow mit der aktuellen Wegpunktliste. */ val waypointsFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" @@ -72,6 +85,33 @@ class WaypointStore(private val context: Context) { }.getOrDefault(emptyMap()) } + /** + * v2.4.6 — Persistente Tourrouten als Map (tourName → TourRoute). + * Rückwärtskompatibel: fehlt der Key komplett, ist die Map leer. + * Auch korrupte JSON werden defensiv auf leer abgefangen, damit eine + * einzelne kaputte Route nicht den Rest der App blockiert. + */ + val tourRoutesFlow: Flow> = + context.dataStore.data.map { prefs -> + val json = prefs[KEY_TOUR_ROUTES] ?: "{}" + runCatching { + gson.fromJson>(json, tourRoutesMapType) ?: emptyMap() + }.getOrDefault(emptyMap()) + } + + /** + * v2.5.5 — Persistente Tour-Cover als Map (tourName → TourCover). + * Rückwärtskompatibel: fehlt der Key, ist die Map leer und Touren + * bekommen ihre bisherige deterministische Hero-Optik. + */ + val tourCoversFlow: Flow> = + context.dataStore.data.map { prefs -> + val json = prefs[KEY_TOUR_COVERS] ?: "{}" + runCatching { + gson.fromJson>(json, tourCoversMapType) ?: emptyMap() + }.getOrDefault(emptyMap()) + } + /** GPS-Track-Flow: Map tour → Liste von TrackPoint. */ val gpsTracksFlow: Flow>> = context.dataStore.data.map { prefs -> @@ -82,6 +122,59 @@ class WaypointStore(private val context: Context) { }.getOrDefault(emptyMap()) } + /** + * Reaktiver Flow der archivierten Track-Drafts. + * Drafts sind eigenständige, persistierte Aufzeichnungen — neue + * Aufzeichnungen werden als zusätzliche Einträge gespeichert, + * niemals durch Überschreiben. + */ + val trackDraftsFlow: Flow> = context.dataStore.data.map { prefs -> + val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" + runCatching { + gson.fromJson>(json, draftListType) ?: emptyList() + }.getOrDefault(emptyList()) + } + + /** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */ + suspend fun addTrackDraft(draft: TrackDraft) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" + val list: MutableList = runCatching { + gson.fromJson>(json, draftListType)?.toMutableList() + }.getOrNull() ?: mutableListOf() + list.add(draft) + prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) + } + } + + /** + * Aktualisiert einen vorhandenen Draft anhand seiner ID. + * Falls noch nicht vorhanden, wird er hinzugefügt (defensiv). + */ + suspend fun upsertTrackDraft(draft: TrackDraft) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" + val list: MutableList = runCatching { + gson.fromJson>(json, draftListType)?.toMutableList() + }.getOrNull() ?: mutableListOf() + val idx = list.indexOfFirst { it.id == draft.id } + if (idx >= 0) list[idx] = draft else list.add(draft) + prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) + } + } + + /** Löscht einen Track-Draft anhand seiner ID. */ + suspend fun deleteTrackDraft(id: String) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" + val list: MutableList = runCatching { + gson.fromJson>(json, draftListType)?.toMutableList() + }.getOrNull() ?: mutableListOf() + list.removeAll { it.id == id } + prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) + } + } + /** Speichert die gesamte Wegpunktliste. */ suspend fun saveAll(waypoints: List) { context.dataStore.edit { prefs -> @@ -131,6 +224,125 @@ 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. + */ + suspend fun saveTourRoute(tourName: String, route: TourRoute) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_ROUTES] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + val stamped = route.copy( + updatedAtMillis = if (route.updatedAtMillis > 0) route.updatedAtMillis + else System.currentTimeMillis() + ) + map[tourName] = stamped + prefs[KEY_TOUR_ROUTES] = gson.toJson(map) + } + } + + /** Entfernt die gespeicherte Route einer Tour (falls vorhanden). */ + suspend fun deleteTourRoute(tourName: String) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_ROUTES] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + if (map.remove(tourName) != null) { + prefs[KEY_TOUR_ROUTES] = gson.toJson(map) + } + } + } + + /** + * Benennt die Tour einer gespeicherten Route um. Wird aus + * [WaypointViewModel.renameTour]/[deleteTour] aufgerufen, damit die + * Route nicht verwaist zurückbleibt. + */ + suspend fun renameTourRouteKey(oldTour: String, newTour: String) { + if (oldTour == newTour) return + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_ROUTES] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + val existing = map.remove(oldTour) ?: return@edit + map[newTour] = existing.copy(updatedAtMillis = System.currentTimeMillis()) + prefs[KEY_TOUR_ROUTES] = gson.toJson(map) + } + } + + /** Bulk-Schreiben der Routen (für Backup-Import). Vorhandene Keys werden überschrieben. */ + suspend fun mergeTourRoutes(routes: Map) { + if (routes.isEmpty()) return + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_ROUTES] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + map.putAll(routes) + prefs[KEY_TOUR_ROUTES] = gson.toJson(map) + } + } + + /** v2.5.5 — Speichert das Cover einer Tour (überschreibt vorhandenes). */ + suspend fun saveTourCover(tourName: String, cover: TourCover) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_COVERS] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourCoversMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + map[tourName] = cover + prefs[KEY_TOUR_COVERS] = gson.toJson(map) + } + } + + /** v2.5.5 — Entfernt das Cover einer Tour (falls vorhanden). */ + suspend fun deleteTourCover(tourName: String) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_COVERS] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourCoversMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + if (map.remove(tourName) != null) { + prefs[KEY_TOUR_COVERS] = gson.toJson(map) + } + } + } + + /** + * v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen, damit er nicht + * unter einem verwaisten Tournamen liegen bleibt. + */ + suspend fun renameTourCoverKey(oldTour: String, newTour: String) { + if (oldTour == newTour) return + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_COVERS] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourCoversMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + val existing = map.remove(oldTour) ?: return@edit + map[newTour] = existing + prefs[KEY_TOUR_COVERS] = gson.toJson(map) + } + } + + /** v2.5.5 — Bulk-Schreiben der Cover (für Backup-Import). */ + suspend fun mergeTourCovers(covers: Map) { + if (covers.isEmpty()) return + context.dataStore.edit { prefs -> + val json = prefs[KEY_TOUR_COVERS] ?: "{}" + val map: MutableMap = runCatching { + gson.fromJson>(json, tourCoversMapType)?.toMutableMap() + }.getOrNull() ?: mutableMapOf() + map.putAll(covers) + prefs[KEY_TOUR_COVERS] = gson.toJson(map) + } + } + /** Löscht den GPS-Track einer Tour. */ suspend fun clearGpsTrack(tourName: String) { context.dataStore.edit { prefs -> diff --git a/app/src/main/kotlin/de/waypointaudio/io/BackupExportManager.kt b/app/src/main/kotlin/de/waypointaudio/io/BackupExportManager.kt new file mode 100644 index 0000000..1230d2b --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/io/BackupExportManager.kt @@ -0,0 +1,446 @@ +package de.waypointaudio.io + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.google.gson.GsonBuilder +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.TourAudioSettings +import de.waypointaudio.data.TourCover +import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.TourRoute +import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.effectiveClips +import java.io.BufferedOutputStream +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Erstellt ein ZIP-Backup mit ausgewählten Datenkategorien. + * + * ZIP-Aufbau: + * manifest.json – Zeitstempel, App-Version, ausgewählte Sektionen + * data/tours.json – Wegpunkte + Tourliste + Tour-Vorgaben (wenn Touren gewählt) + * data/track_drafts.json – GPS-Track-Aufzeichnungen pro Tour (wenn Track-Entwürfe gewählt) + * data/music_settings.json – Begleitmusik-Einstellungen pro Tour (wenn Medien/Atmo gewählt) + * data/settings.json – einfache App-Einstellungen (wenn Einstellungen gewählt) + * media/... – referenzierte Mediendateien (wenn zugänglich) + * media_manifest.json – Liste der erfassten / nicht erreichbaren Mediendateien + * + * v2.5.31 — Bugfix: Tour-Cover-Bilder (frei gewählte Bilder) werden jetzt + * zuverlässig ins Backup aufgenommen. + * + * Ursache des Bugs: + * - Die ZIP-Entry-Namen für Cover-Bilder basierten ausschließlich auf dem + * sanitisierten Tournamen ("media/covers/.jpg"). Bei zwei Touren + * mit gleichem sanitisierten Namen kollidierten die Einträge, ZipOutputStream + * warf eine Exception, und das Cover wurde stillschweigend als null exportiert. + * - Jetzt werden die ZIP-Pfade mit einem Index-Präfix eindeutig gemacht: + * "media/covers/_.jpg". + * - Zusätzlich: Wenn imageUri ein absoluter Pfad ist und die Datei nicht + * (mehr) existiert, wird als Fallback versucht, die URI via ContentResolver + * zu lesen (für legacy content://-Pfade in zukünftigen Migrations-Pfaden). + */ +object BackupExportManager { + + private const val TAG = "BackupExportManager" + private val gson = GsonBuilder().setPrettyPrinting().create() + + data class BackupSelection( + val toursAndWaypoints: Boolean = true, + val trackDrafts: Boolean = true, + val mediaAndAtmo: Boolean = true, + val settings: Boolean = true + ) + + data class BackupInput( + val waypoints: List = emptyList(), + val tours: List = emptyList(), + val tourDefaults: Map = emptyMap(), + val gpsTracks: Map> = emptyMap(), + val musicSettings: Map = emptyMap(), + // v2.4.6 — Tourrouten gehören zur Tour und werden mit ihr exportiert. + val tourRoutes: Map = emptyMap(), + // v2.5.5 — Tour-Cover (Icon + Palette) sind ein reines UI-Detail pro Tour + // und wandern mit dem Tour-Export mit. + val tourCovers: Map = emptyMap(), + val appVersionName: String = "", + val appVersionCode: Int = 0 + ) + + data class BackupResult( + val errorMessage: String? = null, + val mediaCopied: Int = 0, + val mediaFailed: Int = 0, + val sectionsWritten: List = emptyList() + ) + + /** + * Schreibt ein ZIP-Backup an die Ziel-URI. Robust: Fehler einzelner Dateien + * werden im media_manifest erfasst, statt das ganze Backup abzubrechen. + */ + fun writeBackup( + context: Context, + targetUri: Uri, + selection: BackupSelection, + input: BackupInput + ): BackupResult { + return runCatching { + val out: OutputStream = context.contentResolver.openOutputStream(targetUri) + ?: return BackupResult(errorMessage = "Konnte Datei nicht öffnen: $targetUri") + + val mediaCollected = mutableListOf() + val mediaMissing = mutableListOf() + val sectionsWritten = mutableListOf() + + ZipOutputStream(BufferedOutputStream(out)).use { zip -> + // ----- manifest.json ----- + val manifest = mapOf( + "format" to "GPS2Audio-Backup", + "formatVersion" to 1, + "createdAt" to SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US + ).format(Date()), + "appVersionName" to input.appVersionName, + "appVersionCode" to input.appVersionCode, + "sections" to mapOf( + "toursAndWaypoints" to selection.toursAndWaypoints, + "trackDrafts" to selection.trackDrafts, + "mediaAndAtmo" to selection.mediaAndAtmo, + "settings" to selection.settings + ) + ) + writeJsonEntry(zip, "manifest.json", manifest) + + // ----- data/tours.json ----- + if (selection.toursAndWaypoints) { + // v2.5.7 — Tour-Cover-Bilder mitnehmen: die in TourCover hinterlegten + // absoluten Dateipfade verweisen auf app-interne Dateien. Wir packen + // sie unter `media/covers/_.jpg` ins ZIP. Im exportierten + // JSON wird der `imageUri` durch den relativen ZIP-Pfad ersetzt, + // damit der Import die Datei auf eine neue app-interne Ablage + // umbiegen kann, ohne auf den ursprünglichen, absoluten Pfad + // angewiesen zu sein. + // + // v2.5.31 FIX: Jeder ZIP-Eintrag bekommt ein Index-Präfix, + // damit Touren mit gleichem sanitisierten Namen nicht kollidieren. + val exportedCovers = mutableMapOf() + val coverImagesCopied = mutableListOf() + // Menge bereits verwendeter ZIP-Pfade zur Kollisionsvermeidung. + val usedZipPaths = mutableSetOf() + var coverIdx = 0 + + input.tourCovers.forEach { (tour, cover) -> + val imgPath = cover.imageUri + if (!imgPath.isNullOrBlank()) { + // v2.5.31: Eindeutiger ZIP-Eintrag durch Index-Präfix, + // verhindert ZipException bei gleichnamigen Touren. + val safeTour = sanitize(tour).ifBlank { "cover" } + var zipPath: String + do { + zipPath = "media/covers/${String.format(Locale.US, "%03d", coverIdx)}_${safeTour}.jpg" + coverIdx++ + } while (zipPath in usedZipPaths) + usedZipPaths.add(zipPath) + + // Versuche, die Bilddaten zu lesen. + // Pfad 1: absoluter Dateipfad (Normalfall nach saveFromUri). + // Pfad 2: Fallback via ContentResolver, falls imageUri ein + // content://-URI ist (legacy oder Migration). + val imageBytes: ByteArray? = readCoverImageBytes(context, imgPath) + + if (imageBytes != null && imageBytes.isNotEmpty()) { + runCatching { + zip.putNextEntry(ZipEntry(zipPath)) + zip.write(imageBytes) + zip.closeEntry() + exportedCovers[tour] = cover.copy(imageUri = zipPath) + coverImagesCopied.add(tour) + }.onFailure { e -> + Log.w(TAG, "Cover-ZIP-Eintrag fehlgeschlagen Tour: $tour, Fehler: ${e.localizedMessage}") + // Bild-Kopie fehlgeschlagen – Cover ohne Bild exportieren, + // damit Icon+Palette weiterhin gerettet werden. + exportedCovers[tour] = cover.copy(imageUri = null) + } + } else { + // Bild nicht lesbar – nur Icon+Palette exportieren. + Log.w(TAG, "Cover-Bild nicht lesbar: Tour=$tour, Pfad=$imgPath") + exportedCovers[tour] = cover.copy(imageUri = null) + } + } else { + exportedCovers[tour] = cover + } + } + + val toursPayload = mapOf( + "waypoints" to input.waypoints, + "tours" to input.tours, + "tourDefaults" to input.tourDefaults, + // v2.4.6 — Tourrouten Teil des Tour-Exports. + "tourRoutes" to input.tourRoutes, + // v2.5.5/2.5.7/2.5.31 — Tour-Cover (Icon + Palette + ggf. + // ZIP-relativer Bildpfad) mitnehmen. + "tourCovers" to exportedCovers + ) + writeJsonEntry(zip, "data/tours.json", toursPayload) + val routeNote = if (input.tourRoutes.isNotEmpty()) + ", ${input.tourRoutes.size} Tourrouten" else "" + val coverNote = if (input.tourCovers.isNotEmpty()) + ", ${input.tourCovers.size} Cover" else "" + val coverImgNote = if (coverImagesCopied.isNotEmpty()) + ", ${coverImagesCopied.size} Cover-Bilder" else "" + sectionsWritten.add("Touren und Wegpunkte (${input.waypoints.size} Wegpunkte, ${input.tours.size} Touren$routeNote$coverNote$coverImgNote)") + } + + // ----- data/track_drafts.json ----- + if (selection.trackDrafts) { + val trackPayload = mapOf( + "tracks" to input.gpsTracks + ) + writeJsonEntry(zip, "data/track_drafts.json", trackPayload) + val pointCount = input.gpsTracks.values.sumOf { it.size } + sectionsWritten.add("Track-Entwürfe (${input.gpsTracks.size} Touren, $pointCount Punkte)") + } + + // ----- data/music_settings.json + media/... ----- + if (selection.mediaAndAtmo) { + val musicPayload = mapOf( + "musicSettings" to input.musicSettings.mapValues { (_, s) -> + mapOf( + "enabled" to s.enabled, + "sourceType" to s.sourceType.name, + "localPlaylist" to s.localPlaylist.map { p -> + mapOf( + "uriString" to p.uriString, + "displayName" to p.displayName + ) + }, + "streamPlaylist" to s.effectiveStreamPlaylist.map { e -> + mapOf( + "url" to e.url, + "name" to e.name + ) + }, + "streamUrl" to s.streamUrl, + "behavior" to s.behavior.name, + "fadeDurationMs" to s.fadeDurationMs, + "duckVolume" to s.duckVolume, + "shuffle" to s.shuffle, + "autoStartAfterWaypoint" to s.autoStartAfterWaypoint + ) + } + ) + writeJsonEntry(zip, "data/music_settings.json", musicPayload) + sectionsWritten.add("Medien/Atmo (${input.musicSettings.size} Tour-Konfigurationen)") + + // Mediendateien einsammeln: alle Atmo-Playlist-URIs + alle Wegpunkt-soundUris + val sources = mutableListOf() + input.musicSettings.forEach { (tour, s) -> + s.localPlaylist.forEachIndexed { idx, item -> + sources.add( + MediaSource( + uriString = item.uriString, + displayName = item.displayName, + category = "atmo", + relativeFolder = "media/atmo/${sanitize(tour)}", + fallbackName = "track_${idx + 1}" + ) + ) + } + } + if (selection.toursAndWaypoints) { + // v2.5.12 — Alle Clips eines Wegpunkts exportieren. + // Legacy-Wegpunkte ohne audioClips werden über + // [effectiveClips] auf einen synthetischen Clip + // abgebildet, sodass der Export beide Welten kennt. + input.waypoints.forEach { wp -> + val tourFolder = sanitize( + wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + ) + val clips = wp.effectiveClips() + // Wenn das Multi-Clip-Modell leer ist und auch + // soundUri leer, gibt es einfach nichts zu exportieren. + clips.forEachIndexed { idx, clip -> + if (clip.hasAudio) { + sources.add( + MediaSource( + uriString = clip.uri, + displayName = clip.displayFileName + .ifBlank { clip.effectiveTitle }, + category = "waypoint", + relativeFolder = + "media/waypoints/$tourFolder", + fallbackName = "${wp.id}_clip_${idx + 1}" + ) + ) + } + } + } + } + + sources.forEach { src -> + val result = copyMediaToZip(context, src, zip) + if (result.success) { + mediaCollected.add(MediaEntry(src.uriString, src.displayName, src.category, result.zipPath, result.sizeBytes, null)) + } else { + mediaMissing.add(MediaEntry(src.uriString, src.displayName, src.category, null, 0L, result.errorMessage)) + } + } + } + + // ----- data/settings.json ----- + if (selection.settings) { + val settingsPayload = mapOf( + "trackMinDistanceMeters" to 5, + "tourDefaults" to input.tourDefaults, + "note" to "Globale Audio-Routing-IDs sind sitzungsspezifisch und werden nicht exportiert." + ) + writeJsonEntry(zip, "data/settings.json", settingsPayload) + sectionsWritten.add("Einstellungen") + } + + // ----- media_manifest.json ----- + val mediaManifest = mapOf( + "collected" to mediaCollected, + "unavailable" to mediaMissing, + "summary" to mapOf( + "collectedCount" to mediaCollected.size, + "unavailableCount" to mediaMissing.size + ) + ) + writeJsonEntry(zip, "media_manifest.json", mediaManifest) + + zip.flush() + } + + BackupResult( + errorMessage = null, + mediaCopied = mediaCollected.size, + mediaFailed = mediaMissing.size, + sectionsWritten = sectionsWritten + ) + }.getOrElse { e -> + BackupResult(errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}") + } + } + + /** + * v2.5.31 — Liest die Rohdaten eines Cover-Bildes. + * + * Strategie: + * 1. Wenn [imgPath] ein absoluter Dateipfad ist und die Datei existiert → + * direkt lesen. + * 2. Wenn [imgPath] ein content://-URI ist (legacy oder Migrationspfad) → + * über ContentResolver lesen. + * 3. Wenn die Datei nicht existiert aber der Pfad wie ein absoluter Pfad + * aussieht → Fallback: versuche als file://-URI zu parsen. + * + * Gibt null zurück, wenn keiner der Wege funktioniert. + */ + private fun readCoverImageBytes(context: Context, imgPath: String): ByteArray? { + return runCatching { + // Pfad 1: direkt als Datei lesen (Normalfall) + val file = java.io.File(imgPath) + if (file.exists() && file.canRead()) { + return file.readBytes() + } + + // Pfad 2: URI-basierter Zugriff (content:// oder file://) + if (imgPath.startsWith("content://") || imgPath.startsWith("file://")) { + val uri = Uri.parse(imgPath) + return context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + + // Pfad 3: Absoluter Pfad der nicht direkt existiert — versuche als file:// URI + val fileUri = Uri.fromFile(file) + context.contentResolver.openInputStream(fileUri)?.use { it.readBytes() } + }.onFailure { e -> + Log.w(TAG, "readCoverImageBytes fehlgeschlagen fuer: $imgPath, Fehler: ${e.localizedMessage}") + }.getOrNull() + } + + private fun writeJsonEntry(zip: ZipOutputStream, path: String, payload: Any) { + zip.putNextEntry(ZipEntry(path)) + zip.write(gson.toJson(payload).toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + + private data class MediaSource( + val uriString: String, + val displayName: String, + val category: String, + val relativeFolder: String, + val fallbackName: String + ) + + private data class MediaEntry( + val sourceUri: String, + val displayName: String, + val category: String, + val zipPath: String?, + val sizeBytes: Long, + val error: String? + ) + + private data class CopyResult( + val success: Boolean, + val zipPath: String = "", + val sizeBytes: Long = 0L, + val errorMessage: String? = null + ) + + /** + * Versucht, die Mediendatei in das ZIP zu kopieren. Schlägt der Zugriff fehl, + * wird der Eintrag im media_manifest.json als "unavailable" vermerkt – das + * gesamte Backup bricht NICHT ab. + */ + private fun copyMediaToZip( + context: Context, + src: MediaSource, + zip: ZipOutputStream + ): CopyResult { + return runCatching { + val uri = Uri.parse(src.uriString) + val fileName = pickFileName(src) + val zipPath = "${src.relativeFolder}/$fileName" + val input = context.contentResolver.openInputStream(uri) + ?: return CopyResult(false, errorMessage = "InputStream nicht verfügbar") + + input.use { ins -> + zip.putNextEntry(ZipEntry(zipPath)) + val buf = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = ins.read(buf) + if (n <= 0) break + zip.write(buf, 0, n) + total += n + } + zip.closeEntry() + CopyResult(success = true, zipPath = zipPath, sizeBytes = total) + } + }.getOrElse { e -> + // closeEntry darf nicht doppelt aufgerufen werden – ZipOutputStream ist + // robust genug, aber hier einfach Fehler zurückgeben. + CopyResult(false, errorMessage = e.localizedMessage ?: e.javaClass.simpleName) + } + } + + private fun pickFileName(src: MediaSource): String { + val candidate = src.displayName.ifBlank { src.fallbackName } + val safe = sanitize(candidate) + return if (safe.isBlank()) "media_${System.nanoTime()}" else safe + } + + private fun sanitize(s: String): String { + // Ersetze Pfad- und Steuerzeichen durch "_". Mehrfach-Unterstriche normalisieren. + return s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_") + .replace(Regex("_+"), "_") + .trim('_', ' ', '.') + .take(120) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/io/BackupImportManager.kt b/app/src/main/kotlin/de/waypointaudio/io/BackupImportManager.kt new file mode 100644 index 0000000..2a56f48 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/io/BackupImportManager.kt @@ -0,0 +1,788 @@ +package de.waypointaudio.io + +import android.content.Context +import android.net.Uri +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.reflect.TypeToken +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.MusicSourceType +import de.waypointaudio.data.PlaylistItem +import de.waypointaudio.data.StreamEntry +import de.waypointaudio.data.TourAudioSettings +import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.TourCover +import de.waypointaudio.data.TourRoute +import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.WaypointMusicBehavior +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * Liest und importiert ein vom [BackupExportManager] erzeugtes ZIP-Backup. + * + * Strategie: Merge-only. Bestehende Daten werden NICHT überschrieben. + * - Touren mit gleichem Namen werden beim Import umbenannt mit Suffix " (Import)" + * bzw. " (Import 2)", … bis ein freier Name gefunden ist. + * - Wegpunkte aus solchen Touren werden mitgewandert (tourName aktualisiert). + * - Wegpunkt-IDs werden bei Kollision durch neue UUIDs ersetzt. + * - Track-Entwürfe werden zur jeweiligen (ggf. umbenannten) Tour gespeichert. + * - Begleitmusik-Einstellungen werden nur für Touren importiert, die noch keine + * Konfiguration haben (per Tour). Für umbenannte Touren wird die importierte + * Konfiguration unter dem neuen Namen abgelegt. + * - Medien aus `media/atmo/...` und `media/waypoints/...` werden in den + * App-internen `files/`-Ordner extrahiert (als `file://`-URIs adressierbar). + * Nach dem Entpacken werden die importierten Atmo-Playlist- und + * Wegpunkt-`soundUri`-Verweise nach Möglichkeit auf die extrahierten Dateien + * umgeschrieben. Verbleibende `content://`-URIs bleiben als Best-Effort + * erhalten – Hinweistext in [ImportResult.warnings]. + */ +object BackupImportManager { + + private val gson: Gson = GsonBuilder().create() + + data class ImportSelection( + val toursAndWaypoints: Boolean = true, + val trackDrafts: Boolean = true, + val mediaAndAtmo: Boolean = true, + val settings: Boolean = true + ) + + /** + * Liste der vom Manifest deklarierten Sektionen UND die im ZIP tatsächlich + * vorhandenen Sektionen (für UI-Vorauswahl). + */ + data class ManifestInfo( + val format: String?, + val formatVersion: Int?, + val createdAt: String?, + val appVersionName: String?, + val appVersionCode: Int?, + val sectionsAvailable: SectionAvailability, + val errorMessage: String? = null + ) + + data class SectionAvailability( + val toursAndWaypoints: Boolean = false, + val trackDrafts: Boolean = false, + val mediaAndAtmo: Boolean = false, + val settings: Boolean = false + ) + + data class ImportResult( + val errorMessage: String? = null, + val toursImported: Int = 0, + val toursRenamed: Int = 0, + val waypointsImported: Int = 0, + val tourDefaultsImported: Int = 0, + val tracksImported: Int = 0, + val musicSettingsImported: Int = 0, + val mediaExtracted: Int = 0, + val mediaSkipped: Int = 0, + val warnings: List = emptyList(), + val newTourSelection: String? = null + ) + + /** Ergebnis-Daten, das der Aufrufer (ViewModel) in die DataStores schreibt. */ + data class ImportPayload( + val mergedWaypoints: List, + val mergedTours: List, + val tourDefaultsToWrite: Map, + val tracksToWrite: Map>, + val musicToWrite: Map, + // v2.4.6 — Tourrouten, die geschrieben werden sollen (Map endgültiger Tourname → Route). + val tourRoutesToWrite: Map = emptyMap(), + // v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover). + val tourCoversToWrite: Map = emptyMap(), + val trackMinDistanceMeters: Int?, + val newTourSelection: String?, + val warnings: List, + val toursImported: Int, + val toursRenamed: Int, + val waypointsImported: Int, + val tracksImported: Int, + val musicSettingsImported: Int, + val mediaExtracted: Int, + val mediaSkipped: Int, + val tourDefaultsImported: Int, + val tourRoutesImported: Int = 0, + val tourCoversImported: Int = 0 + ) + + /** + * Liest das Manifest und gibt vorhandene Sektionen zurück, ohne weitere + * Daten anzuwenden. Nutzt SAF-`Uri`. Off-UI-Thread aufrufen. + */ + fun readManifest(context: Context, sourceUri: Uri): ManifestInfo { + return runCatching { + val ins = context.contentResolver.openInputStream(sourceUri) + ?: return ManifestInfo(null, null, null, null, null, + SectionAvailability(), + errorMessage = "Datei konnte nicht geöffnet werden.") + + var manifestJson: JsonObject? = null + val available = mutableMapOf( + "tours" to false, + "drafts" to false, + "music" to false, + "settings" to false + ) + + ZipInputStream(ins).use { zin -> + while (true) { + val e = zin.nextEntry ?: break + when (e.name) { + "manifest.json" -> { + val bytes = zin.readBytes() + manifestJson = JsonParser.parseString(String(bytes, Charsets.UTF_8)) + .asJsonObject + } + "data/tours.json" -> available["tours"] = true + "data/track_drafts.json" -> available["drafts"] = true + "data/music_settings.json" -> available["music"] = true + "data/settings.json" -> available["settings"] = true + } + zin.closeEntry() + } + } + + if (manifestJson == null) { + return ManifestInfo( + format = null, formatVersion = null, createdAt = null, + appVersionName = null, appVersionCode = null, + sectionsAvailable = SectionAvailability(), + errorMessage = "Kein gültiges Backup: manifest.json fehlt." + ) + } + val mf = manifestJson!! + val format = mf.get("format")?.asString + val formatVersion = mf.get("formatVersion")?.asInt + if (format != "GPS2Audio-Backup") { + return ManifestInfo( + format = format, formatVersion = formatVersion, createdAt = null, + appVersionName = null, appVersionCode = null, + sectionsAvailable = SectionAvailability(), + errorMessage = "Format wird nicht unterstützt: $format" + ) + } + ManifestInfo( + format = format, + formatVersion = formatVersion, + createdAt = mf.get("createdAt")?.asString, + appVersionName = mf.get("appVersionName")?.asString, + appVersionCode = mf.get("appVersionCode")?.asInt, + sectionsAvailable = SectionAvailability( + toursAndWaypoints = available["tours"] == true, + trackDrafts = available["drafts"] == true, + mediaAndAtmo = available["music"] == true, + settings = available["settings"] == true + ) + ) + }.getOrElse { e -> + ManifestInfo( + format = null, formatVersion = null, createdAt = null, + appVersionName = null, appVersionCode = null, + sectionsAvailable = SectionAvailability(), + errorMessage = "Backup konnte nicht gelesen werden: ${e.localizedMessage ?: e.javaClass.simpleName}" + ) + } + } + + /** + * Liest das Backup und berechnet die zu schreibenden, gemergten Daten. + * Schreibt KEINE DataStore-Daten – das macht der Aufrufer (ViewModel). + * Schreibt jedoch die extrahierten Mediendateien in den App-internen Speicher. + */ + fun buildImportPayload( + context: Context, + sourceUri: Uri, + selection: ImportSelection, + existingWaypoints: List, + existingTours: List, + existingTourDefaults: Map, + existingTracks: Map>, + existingMusic: Map, + // v2.4.6 — bestehende Tourrouten, damit wir keine bereits vorhandene Route überschreiben. + existingTourRoutes: Map = emptyMap(), + // v2.5.5 — bestehende Tour-Cover, damit eigene Auswahl nicht überschrieben wird. + existingTourCovers: Map = emptyMap() + ): Pair { + return runCatching { + // ---- ZIP einlesen (alle JSON in Memory, Medien in interne Datei extrahieren) ---- + val mediaTargetDir = File(context.filesDir, "imported_media").apply { mkdirs() } + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val sessionDir = File(mediaTargetDir, timestamp).apply { mkdirs() } + + // Map: zip-pfad → file://-URI der extrahierten Datei + val extractedMedia = mutableMapOf() + // Mapping originaler displayName/dateiname → zip-pfade (für Best-Effort-Match) + val mediaByLeafName = mutableMapOf>() + + var manifestJson: JsonObject? = null + var toursJson: String? = null + var draftsJson: String? = null + var musicJson: String? = null + var settingsJson: String? = null + + val ins0 = context.contentResolver.openInputStream(sourceUri) + ?: return null to "Datei konnte nicht geöffnet werden." + ZipInputStream(ins0).use { zin -> + while (true) { + val e = zin.nextEntry ?: break + when { + e.isDirectory -> {} + e.name == "manifest.json" -> { + manifestJson = JsonParser.parseString(zin.readBytes().toString(Charsets.UTF_8)) + .asJsonObject + } + e.name == "data/tours.json" -> + toursJson = zin.readBytes().toString(Charsets.UTF_8) + e.name == "data/track_drafts.json" -> + draftsJson = zin.readBytes().toString(Charsets.UTF_8) + e.name == "data/music_settings.json" -> + musicJson = zin.readBytes().toString(Charsets.UTF_8) + e.name == "data/settings.json" -> + settingsJson = zin.readBytes().toString(Charsets.UTF_8) + // Mediendateien + e.name.startsWith("media/") && + !e.name.endsWith("/") -> { + // v2.5.7 — Cover-Bilder (media/covers/...) gehören zu + // "Touren und Wegpunkte" und werden auch dann extrahiert, + // wenn Medien/Atmo nicht gewählt wurde, damit Tour-Cover + // beim Import nicht verloren gehen. + val isCoverFile = e.name.startsWith("media/covers/") + val shouldExtract = + (isCoverFile && selection.toursAndWaypoints) || + (!isCoverFile && selection.mediaAndAtmo) + if (shouldExtract) { + val outFile = safeMediaTarget(sessionDir, e) + if (outFile != null) { + FileOutputStream(outFile).use { fout -> + zin.copyTo(fout) + } + val uri = Uri.fromFile(outFile).toString() + extractedMedia[e.name] = uri + val leaf = outFile.name.lowercase(Locale.US) + mediaByLeafName.getOrPut(leaf) { mutableListOf() } + .add(e.name) + } + } + } + else -> { /* Ignorieren (z. B. media_manifest.json) */ } + } + zin.closeEntry() + } + } + + if (manifestJson == null) { + return null to "Kein gültiges Backup: manifest.json fehlt." + } + val mf = manifestJson!! + if (mf.get("format")?.asString != "GPS2Audio-Backup") { + return null to "Format wird nicht unterstützt." + } + + val warnings = mutableListOf() + + // ---- Tours + Waypoints ---- + val resultTours = existingTours.toMutableList() + // Mapping: original importierter Tourname → endgültiger Name (ggf. umbenannt) + val tourRename = mutableMapOf() + var toursImported = 0 + var toursRenamed = 0 + var newTourSelection: String? = null + + val mergedWaypoints = existingWaypoints.toMutableList() + val existingIds = existingWaypoints.map { it.id }.toMutableSet() + var waypointsImported = 0 + val tourDefaultsToWrite = mutableMapOf() + var tourDefaultsImported = 0 + // v2.4.6 — Tourrouten Merge-Buffer. + val tourRoutesToWrite = mutableMapOf() + var tourRoutesImported = 0 + // v2.5.5 — Tour-Cover Merge-Buffer. + val tourCoversToWrite = mutableMapOf() + var tourCoversImported = 0 + + if (selection.toursAndWaypoints && toursJson != null) { + val payload = runCatching { + JsonParser.parseString(toursJson).asJsonObject + }.getOrNull() + if (payload != null) { + val importedTourList: List = runCatching { + gson.fromJson>( + payload.get("tours"), + object : TypeToken>() {}.type + ) ?: emptyList() + }.getOrDefault(emptyList()) + + importedTourList.forEach { rawName -> + val name = rawName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + if (name == Waypoint.DEFAULT_TOUR_NAME) { + // Standard-Tour wird nicht umbenannt – Inhalte werden gemergt. + tourRename[name] = name + } else if (name in resultTours) { + val unique = uniqueName(name, resultTours) + tourRename[name] = unique + resultTours.add(unique) + toursRenamed++ + toursImported++ + } else { + tourRename[name] = name + resultTours.add(name) + toursImported++ + } + } + if (toursImported > 0) { + // Ersten neu importierten/umbenannten Tournamen für UI vormerken + newTourSelection = importedTourList + .firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME } + ?.let { tourRename[it] } + } + + val importedWps: List = runCatching { + gson.fromJson>( + payload.get("waypoints"), + object : TypeToken>() {}.type + ) ?: emptyList() + }.getOrDefault(emptyList()) + + importedWps.forEach { w -> + val originalTour = w.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val mappedTour = tourRename[originalTour] + ?: run { + // Tour war nicht in `tours`, also als neue/aliased aufnehmen + if (originalTour == Waypoint.DEFAULT_TOUR_NAME || + originalTour !in resultTours + ) { + if (originalTour != Waypoint.DEFAULT_TOUR_NAME) { + resultTours.add(originalTour) + } + tourRename[originalTour] = originalTour + originalTour + } else { + val unique = uniqueName(originalTour, resultTours) + tourRename[originalTour] = unique + resultTours.add(unique) + toursRenamed++ + unique + } + } + + val newId = if (w.id in existingIds) UUID.randomUUID().toString() else w.id + existingIds.add(newId) + + val tourMediaFolder = + "media/waypoints/${sanitize(originalTour)}/" + + val rewrittenSoundUri = rewriteMediaReference( + originalUri = w.soundUri, + originalDisplayName = w.soundName, + extractedMedia = extractedMedia, + mediaByLeafName = mediaByLeafName, + preferZipFolder = tourMediaFolder + ) + + // v2.5.12 — Auch die URIs der Multi-Clip-Liste auf + // extrahierte Dateien umschreiben. Alte Backups ohne + // audioClips erzeugen hier eine leere Liste – die + // Migration auf Legacy-Felder erfolgt zur Laufzeit + // über [effectiveClips]. + val rewrittenClips = w.audioClips.map { c -> + val newUri = rewriteMediaReference( + originalUri = c.uri, + originalDisplayName = c.displayFileName + .ifBlank { c.title }, + extractedMedia = extractedMedia, + mediaByLeafName = mediaByLeafName, + preferZipFolder = tourMediaFolder + ) ?: c.uri + c.copy(uri = newUri) + } + + mergedWaypoints.add( + w.copy( + id = newId, + tourName = mappedTour, + soundUri = rewrittenSoundUri ?: w.soundUri, + audioClips = rewrittenClips + ) + ) + waypointsImported++ + } + + val tourDefaultsImportedRaw: Map = runCatching { + gson.fromJson>( + payload.get("tourDefaults"), + object : TypeToken>() {}.type + ) ?: emptyMap() + }.getOrDefault(emptyMap()) + tourDefaultsImportedRaw.forEach { (origName, def) -> + val mapped = tourRename[origName] ?: origName + if (mapped !in existingTourDefaults) { + tourDefaultsToWrite[mapped] = def.copy(tourName = mapped) + tourDefaultsImported++ + } + } + + // v2.4.6 — Tourrouten aus tours.json (optional, rückwärtskompatibel). + val tourRoutesImportedRaw: Map = runCatching { + gson.fromJson>( + payload.get("tourRoutes"), + object : TypeToken>() {}.type + ) ?: emptyMap() + }.getOrDefault(emptyMap()) + tourRoutesImportedRaw.forEach { (origName, route) -> + val mapped = tourRename[origName] ?: origName + if (mapped !in existingTourRoutes) { + tourRoutesToWrite[mapped] = route.copy( + updatedAtMillis = System.currentTimeMillis() + ) + tourRoutesImported++ + } else { + warnings.add("Tourroute für „$mapped“ bereits vorhanden – nicht überschrieben.") + } + } + + // v2.5.5/2.5.7/2.5.31 — Tour-Cover aus tours.json (optional, rückwärtskompatibel). + // Falls Cover-Bild vorhanden ist (imageUri = ZIP-relativer Pfad + // wie "media/covers/_.jpg" (v2.5.31+) oder + // "media/covers/.jpg" (v2.5.7–v2.5.30)), wird die extrahierte + // Datei via [TourCoverImageStore.saveFromFile] in die app-interne + // Ablage übernommen. Schlägt das fehl, bleibt Icon+Palette + // erhalten und das Bild entfällt. + // + // Rückwärtskompatibilität: Beide Pfad-Formate werden erkannt, da + // der extractedMedia-Index immer den originalen ZIP-Entry-Namen + // (e.name) als Schlüssel verwendet. + val tourCoversImportedRaw: Map = runCatching { + gson.fromJson>( + payload.get("tourCovers"), + object : TypeToken>() {}.type + ) ?: emptyMap() + }.getOrDefault(emptyMap()) + tourCoversImportedRaw.forEach { (origName, cover) -> + val mapped = tourRename[origName] ?: origName + if (mapped !in existingTourCovers) { + val zipRelativeImage = cover.imageUri + val resolvedImagePath: String? = if ( + !zipRelativeImage.isNullOrBlank() && + zipRelativeImage.startsWith("media/covers/") + ) { + val extractedFileUri = extractedMedia[zipRelativeImage] + if (extractedFileUri != null) { + runCatching { + val path = Uri.parse(extractedFileUri).path + if (path != null) { + de.waypointaudio.util.TourCoverImageStore + .saveFromFile(context, java.io.File(path), mapped) + } else { + android.util.Log.w("BackupImportManager", + "Cover-URI-Pfad null für Tour \"$mapped\" (extractedFileUri=$extractedFileUri)") + null + } + }.onFailure { e -> + android.util.Log.w("BackupImportManager", + "saveFromFile fehlgeschlagen für Tour \"$mapped\": ${e.localizedMessage}") + }.getOrNull() + } else { + // Bild-Datei war nicht im ZIP oder wurde nicht extrahiert. + android.util.Log.w("BackupImportManager", + "Cover-Datei \"$zipRelativeImage\" nicht im ZIP für Tour \"$mapped\"") + null + } + } else { + // imageUri ist null, leer oder ein absoluter Pfad + // vom Quell-Gerät – nicht importierbar. + null + } + tourCoversToWrite[mapped] = cover.copy(imageUri = resolvedImagePath) + tourCoversImported++ + } + } + } else { + warnings.add("data/tours.json konnte nicht gelesen werden.") + } + } + + // ---- Track-Entwürfe ---- + val tracksToWrite = mutableMapOf>() + var tracksImported = 0 + if (selection.trackDrafts && draftsJson != null) { + val obj = runCatching { + JsonParser.parseString(draftsJson).asJsonObject + }.getOrNull() + val map: Map> = if (obj != null) { + runCatching { + gson.fromJson>>( + obj.get("tracks"), + object : TypeToken>>() {}.type + ) ?: emptyMap() + }.getOrDefault(emptyMap()) + } else emptyMap() + + map.forEach { (origTour, points) -> + val mappedTour = tourRename[origTour] + ?: run { + // Tour war nicht im Tour-Import (z. B. wenn nur Drafts importiert werden): + // prüfen, ob Name kollidiert. + if (origTour == Waypoint.DEFAULT_TOUR_NAME) { + origTour + } else if (origTour in resultTours) { + origTour + } else { + resultTours.add(origTour) + origTour + } + } + val existingPoints = existingTracks[mappedTour] ?: emptyList() + if (existingPoints.isEmpty()) { + // Bestehenden Track NICHT überschreiben. + tracksToWrite[mappedTour] = points + tracksImported++ + } else { + warnings.add("Track-Entwurf für Tour „$mappedTour\" bereits vorhanden – nicht überschrieben.") + } + } + } + + // ---- Begleitmusik ---- + val musicToWrite = mutableMapOf() + var musicSettingsImported = 0 + if (selection.mediaAndAtmo && musicJson != null) { + val obj = runCatching { + JsonParser.parseString(musicJson).asJsonObject + }.getOrNull() + val mapJson: JsonObject? = obj?.getAsJsonObject("musicSettings") + mapJson?.entrySet()?.forEach { entry -> + val origTour = entry.key + val mappedTour = tourRename[origTour] ?: origTour + if (mappedTour in existingMusic) { + warnings.add("Begleitmusik für Tour „$mappedTour\" bereits vorhanden – nicht überschrieben.") + return@forEach + } + val src = entry.value.asJsonObject + val settings = parseMusicSettings(src) ?: return@forEach + val rewritten = settings.copy( + localPlaylist = settings.localPlaylist.map { item -> + val newUri = rewriteMediaReference( + originalUri = item.uriString, + originalDisplayName = item.displayName, + extractedMedia = extractedMedia, + mediaByLeafName = mediaByLeafName, + preferZipFolder = "media/atmo/${sanitize(origTour)}/" + ) ?: item.uriString + PlaylistItem(uriString = newUri, displayName = item.displayName) + } + ) + musicToWrite[mappedTour] = rewritten + musicSettingsImported++ + } + } + + // ---- Settings ---- + var trackMinDistanceMeters: Int? = null + if (selection.settings && settingsJson != null) { + val obj = runCatching { + JsonParser.parseString(settingsJson).asJsonObject + }.getOrNull() + val v = obj?.get("trackMinDistanceMeters") + if (v != null && v.isJsonPrimitive && v.asJsonPrimitive.isNumber) { + val n = v.asInt + if (n in 1..500) trackMinDistanceMeters = n + } + } + + // ---- Medien-Statistik ---- + val mediaExtracted = extractedMedia.size + val mediaSkipped = if (selection.mediaAndAtmo && extractedMedia.isEmpty()) { + // ZIP enthielt evtl. keine Medien – kein Fehler + 0 + } else 0 + + if (mediaExtracted > 0) { + warnings.add("Importierte Mediendateien wurden nach app-internem Speicher kopiert (interner Pfad).") + } + if (selection.mediaAndAtmo) { + // Falls `content://`-URIs übrig sind, weisen wir darauf hin + val orphanContentUris = mergedWaypoints.sumOf { wp -> + val legacy = if (wp.soundUri.startsWith("content://")) 1 else 0 + val clips = wp.audioClips.count { it.uri.startsWith("content://") } + legacy + clips + } + musicToWrite.values.sumOf { ms -> + ms.localPlaylist.count { it.uriString.startsWith("content://") } + } + if (orphanContentUris > 0) { + warnings.add( + "Hinweis: Einige Verweise zeigen auf externe `content://`-URIs aus dem ursprünglichen Gerät. " + + "Diese sind nach Import meist nicht mehr abspielbar." + ) + } + } + + ImportPayload( + mergedWaypoints = mergedWaypoints, + mergedTours = resultTours.distinct(), + tourDefaultsToWrite = tourDefaultsToWrite, + tracksToWrite = tracksToWrite, + musicToWrite = musicToWrite, + tourRoutesToWrite = tourRoutesToWrite, + tourCoversToWrite = tourCoversToWrite, + trackMinDistanceMeters = trackMinDistanceMeters, + newTourSelection = newTourSelection, + warnings = warnings, + toursImported = toursImported, + toursRenamed = toursRenamed, + waypointsImported = waypointsImported, + tracksImported = tracksImported, + musicSettingsImported = musicSettingsImported, + mediaExtracted = mediaExtracted, + mediaSkipped = mediaSkipped, + tourDefaultsImported = tourDefaultsImported, + tourRoutesImported = tourRoutesImported, + tourCoversImported = tourCoversImported + ) to null + }.getOrElse { e -> + null to "Backup konnte nicht importiert werden: ${e.localizedMessage ?: e.javaClass.simpleName}" + } + } + + // ---------------------------------------------------------------------- + // Hilfsfunktionen + // ---------------------------------------------------------------------- + + private fun uniqueName(base: String, existing: List): String { + val first = "$base (Import)" + if (first !in existing) return first + var i = 2 + while (true) { + val cand = "$base (Import $i)" + if (cand !in existing) return cand + i++ + if (i > 999) return "$base (Import ${System.currentTimeMillis()})" + } + } + + private fun parseMusicSettings(obj: JsonObject): TourAudioSettings? { + return runCatching { + val sourceType = runCatching { + MusicSourceType.valueOf(obj.get("sourceType")?.asString ?: "LOCAL_PLAYLIST") + }.getOrDefault(MusicSourceType.LOCAL_PLAYLIST) + val behavior = runCatching { + WaypointMusicBehavior.valueOf(obj.get("behavior")?.asString ?: "PAUSE_RESUME") + }.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME) + val playlist = obj.getAsJsonArray("localPlaylist")?.mapNotNull { el -> + val o = el.asJsonObject + PlaylistItem( + uriString = o.get("uriString")?.asString ?: return@mapNotNull null, + displayName = o.get("displayName")?.asString ?: "" + ) + } ?: emptyList() + val streamList = obj.getAsJsonArray("streamPlaylist")?.mapNotNull { el -> + val o = el.asJsonObject + val url = o.get("url")?.asString ?: return@mapNotNull null + StreamEntry( + url = url, + name = o.get("name")?.asString ?: "" + ) + } ?: emptyList() + val legacyStreamUrl = obj.get("streamUrl")?.takeIf { !it.isJsonNull }?.asString + // Legacy-Backups (v2.0.7 und älter) hatten nur streamUrl – wenn keine streamPlaylist + // gespeichert wurde, aber legacy-URL vorhanden ist, migrieren wir sie zu einem + // einzelnen StreamEntry. + val effectiveStreams = if (streamList.isEmpty() && !legacyStreamUrl.isNullOrBlank()) { + listOf(StreamEntry(url = legacyStreamUrl, name = "")) + } else streamList + TourAudioSettings( + enabled = obj.get("enabled")?.asBoolean ?: false, + sourceType = sourceType, + localPlaylist = playlist, + streamPlaylist = effectiveStreams, + streamUrl = null, + behavior = behavior, + fadeDurationMs = obj.get("fadeDurationMs")?.asLong ?: 1500L, + duckVolume = obj.get("duckVolume")?.asFloat ?: 0.25f, + shuffle = obj.get("shuffle")?.asBoolean ?: false, + autoStartAfterWaypoint = obj.get("autoStartAfterWaypoint")?.asBoolean ?: false + ) + }.getOrNull() + } + + /** + * Versucht, eine Original-URI (die zur Backup-Zeit eingelesen wurde) auf eine + * frisch extrahierte Datei abzubilden. + * + * 1. Wenn Originalpfad in [extractedMedia] direkt referenziert ist (z. B. + * bei `file://`-URIs aus dem Backup-Manifest), → diese benutzen. + * 2. Sonst: nach `displayName`/Dateiname im bevorzugten Ordner suchen. + * 3. Sonst: nach `displayName`/Dateiname global suchen. + * + * Gibt `null` zurück, wenn keine extrahierte Datei zugeordnet werden konnte. + */ + private fun rewriteMediaReference( + originalUri: String, + originalDisplayName: String, + extractedMedia: Map, + mediaByLeafName: Map>, + preferZipFolder: String + ): String? { + if (originalUri.isBlank()) return null + // 1. Direkter Treffer (Pfad) + extractedMedia[originalUri]?.let { return it } + + // 2. Nach displayName im bevorzugten Ordner + val sanName = sanitize(originalDisplayName.ifBlank { "" }).lowercase(Locale.US) + if (sanName.isNotBlank()) { + val candidates = mediaByLeafName[sanName] ?: emptyList() + val preferred = candidates.firstOrNull { it.startsWith(preferZipFolder) } + if (preferred != null) extractedMedia[preferred]?.let { return it } + // 3. Global + candidates.firstOrNull()?.let { extractedMedia[it]?.let { u -> return u } } + } + + // 4. Letzter Pfad-Komponente der originalen URI versuchen + val tail = originalUri.substringAfterLast('/').substringBefore('?') + .lowercase(Locale.US) + if (tail.isNotBlank()) { + val candidates = mediaByLeafName[tail] ?: emptyList() + val preferred = candidates.firstOrNull { it.startsWith(preferZipFolder) } + if (preferred != null) extractedMedia[preferred]?.let { return it } + candidates.firstOrNull()?.let { extractedMedia[it]?.let { u -> return u } } + } + return null + } + + /** Verhindert Path-Traversal beim Entpacken in einen Zielordner. */ + private fun safeMediaTarget(baseDir: File, entry: ZipEntry): File? { + val rel = entry.name.removePrefix("media/") + if (rel.contains("..")) return null + val safe = rel.split('/').joinToString(File.separator) { sanitizeForFs(it) } + if (safe.isBlank()) return null + val target = File(baseDir, safe) + // Path-Traversal-Schutz: Ziel muss Unterpfad von baseDir bleiben + val basePath = baseDir.canonicalPath + if (!target.canonicalPath.startsWith(basePath)) return null + target.parentFile?.mkdirs() + return target + } + + private fun sanitize(s: String): String { + return s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_") + .replace(Regex("_+"), "_") + .trim('_', ' ', '.') + .take(120) + } + + private fun sanitizeForFs(s: String): String { + if (s.isBlank()) return "_" + return sanitize(s).ifBlank { "_" } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/CanonicalJson.kt b/app/src/main/kotlin/de/waypointaudio/license/CanonicalJson.kt new file mode 100644 index 0000000..7ebae1d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/CanonicalJson.kt @@ -0,0 +1,98 @@ +package de.waypointaudio.license + +import org.json.JSONArray +import org.json.JSONObject + +/** + * v2.5.20 — Kanonische JSON-Serialisierung, kompatibel zum Python-Tool + * (`canonical_json_bytes`): + * - Keys lexikografisch sortiert. + * - Trennzeichen ohne Whitespace: "," / ":". + * - `ensure_ascii=False` ⇒ Unicode-Strings werden nicht escapet, ausser den + * minimal nötigen JSON-Escapes (`\"`, `\\`, Steuerzeichen < 0x20). + * - `null` für JSON-Null; `JSONObject.NULL` und Kotlin-`null` gleich behandelt. + * + * Diese kanonische Form ist die Eingabe für die ECDSA-P256-SHA256-Signatur. + */ +internal object CanonicalJson { + + fun encode(value: Any?): String { + val sb = StringBuilder() + appendValue(sb, value) + return sb.toString() + } + + private fun appendValue(sb: StringBuilder, value: Any?) { + when { + value == null || value === JSONObject.NULL -> sb.append("null") + value is Boolean -> sb.append(if (value) "true" else "false") + value is Number -> appendNumber(sb, value) + value is String -> appendString(sb, value) + value is JSONObject -> appendObject(sb, value) + value is JSONArray -> appendArray(sb, value) + else -> appendString(sb, value.toString()) + } + } + + private fun appendObject(sb: StringBuilder, obj: JSONObject) { + val keys = obj.keys().asSequence().toList().sorted() + sb.append('{') + for ((index, key) in keys.withIndex()) { + if (index > 0) sb.append(',') + appendString(sb, key) + sb.append(':') + appendValue(sb, obj.opt(key)) + } + sb.append('}') + } + + private fun appendArray(sb: StringBuilder, arr: JSONArray) { + sb.append('[') + for (i in 0 until arr.length()) { + if (i > 0) sb.append(',') + appendValue(sb, arr.opt(i)) + } + sb.append(']') + } + + private fun appendNumber(sb: StringBuilder, n: Number) { + when (n) { + is Int, is Long, is Short, is Byte -> sb.append(n.toString()) + is Double -> { + if (n.isNaN() || n.isInfinite()) { + sb.append("null") + } else if (n == n.toLong().toDouble()) { + sb.append(n.toLong().toString()) + } else { + sb.append(n.toString()) + } + } + is Float -> appendNumber(sb, n.toDouble()) + else -> sb.append(n.toString()) + } + } + + private fun appendString(sb: StringBuilder, s: String) { + sb.append('"') + for (c in s) { + when (c.code) { + 0x22 -> sb.append("\\\"") + 0x5C -> sb.append("\\\\") + 0x08 -> sb.append("\\b") + 0x0C -> sb.append("\\f") + 0x0A -> sb.append("\\n") + 0x0D -> sb.append("\\r") + 0x09 -> sb.append("\\t") + else -> { + val code = c.code + if (code < 0x20) { + sb.append("\\u%04x".format(code)) + } else { + sb.append(c) + } + } + } + } + sb.append('"') + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/DeviceIdProvider.kt b/app/src/main/kotlin/de/waypointaudio/license/DeviceIdProvider.kt new file mode 100644 index 0000000..9bffe0b --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/DeviceIdProvider.kt @@ -0,0 +1,73 @@ +package de.waypointaudio.license + +import android.content.Context +import android.content.SharedPreferences +import java.security.MessageDigest +import java.util.UUID + +/** + * v2.5.21 — App-spezifische Geräte-ID für optionale gerätegebundene Lizenzen. + * + * Architektur-Entscheidungen: + * - KEIN Einsatz von Android-Hardware-IDs (ANDROID_ID, Build.SERIAL o.Ä.): + * Diese sind seit Android 8+ pro App/Benutzer unterschiedlich und nicht + * verlässlich. Stattdessen wird beim ersten Aufruf eine zufällige UUID v4 + * generiert und app-intern gespeichert. + * - Speicherort: SharedPreferences in der Datei "gps2audio_license_device" + * (MODE_PRIVATE). Diese Datei liegt außerhalb des normalen Tour-/Medien- + * Backup-Bereichs. Via data_extraction_rules wird sie vom automatischen + * Android-Backup ausgeschlossen (keine sensiblen Daten, aber bewusste + * Entscheidung: wenn der Nutzer App-Daten löscht, darf sich die ID ändern). + * - Die raw UUID dient als Geräte-ID. Für die Anzeige wird ein SHA-256-Hash + * nach dem Schema sha256("GPS2AudioDevice:" + uuid) errechnet, damit das + * Python-Lizenz-Tool dasselbe Format erwartet und verwenden kann. + * - In v2.5.21 KEINE harte Gerätebindung aktiv. Die ID wird nur angezeigt + * und kann kopiert werden. Phase 3 kann die Prüfung in LicenseValidator + * ergänzen, ohne die ID-Erzeugungslogik zu ändern. + * - Wenn App-Daten gelöscht werden, ändert sich die ID. Das ist für Phase 3 + * im Handoff dokumentiert und akzeptiert. + */ +object DeviceIdProvider { + + private const val PREFS_FILE = "gps2audio_license_device" + private const val KEY_DEVICE_UUID = "device_uuid" + + /** + * Liefert die rohe app-spezifische Geräte-UUID. + * Beim ersten Aufruf wird eine neue UUID generiert und persistiert. + */ + fun getDeviceUuid(context: Context): String { + val prefs = prefs(context) + val existing = prefs.getString(KEY_DEVICE_UUID, null) + if (!existing.isNullOrBlank()) return existing + val fresh = UUID.randomUUID().toString() + prefs.edit().putString(KEY_DEVICE_UUID, fresh).apply() + return fresh + } + + /** + * Liefert den SHA-256-Hash der Geräte-ID im Format, das das GPS2Audio- + * Python-Lizenztool erwartet: sha256("GPS2AudioDevice:" + device_uuid). + * Ausgabe: lowercase Hex-String (64 Zeichen). + */ + fun getDeviceIdHash(context: Context): String { + val uuid = getDeviceUuid(context) + val input = "GPS2AudioDevice:$uuid" + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + /** + * Kurzform des Hash für die UI (erste 16 Zeichen + "..." + letzte 8 Zeichen). + * Zeigt z.B. "a1b2c3d4e5f60708...90abcdef" um die Anzeige übersichtlicher + * zu halten. Die Zwischenablage enthält stets den vollen Hash. + */ + fun getDeviceIdHashShort(context: Context): String { + val full = getDeviceIdHash(context) + return if (full.length > 24) "${full.take(16)}…${full.takeLast(8)}" else full + } + + private fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/LicenseFeatureAccess.kt b/app/src/main/kotlin/de/waypointaudio/license/LicenseFeatureAccess.kt new file mode 100644 index 0000000..f52774f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/LicenseFeatureAccess.kt @@ -0,0 +1,101 @@ +package de.waypointaudio.license + +/** + * v2.5.32 — Zentrale Feature-Berechtigungsprüfung. + * + * Diese Klasse kapselt alle Regeln, welche Lizenztypen Zugriff auf + * Profi-Funktionen gewähren. Sie liest ausschließlich aus [LicenseStatus] + * und ändert NIEMALS Lizenzdaten, Import-Logik, Signaturprüfung, + * Geräte-ID, CanonicalJson oder Backup-Regeln. + * + * Lizenztypen mit professionellem Zugang: + * - "pro", "profi", "commercial" → Profi-Zugang solange status == VALID + * - "developer" → immer erlaubt wenn status == VALID + * - "trial" → Profi-Zugang solange status == VALID (valid_until-Prüfung + * erfolgt bereits in LicenseValidator; ist abgelaufen → state == EXPIRED) + * + * Ohne Lizenz (state == NONE) oder mit abgelaufener/ungültiger Lizenz: + * - Keine Profi-Funktion freigegeben. + * - Vorhandene Daten (Zusatzclips, PTT-Aufnahmen, Zählerwerte) werden + * NIEMALS gelöscht. + * + * Feature-Gates: + * - [hasProfessionalAccess]: gemeinsames Haupt-Gate + * - [canUseMultiClip]: Zusatzclips bearbeiten/hinzufügen/aktivieren + * - [canUsePtt]: PTT-Modul anzeigen/nutzen + * - [canUseTourCounter]: Tour-Zähler anzeigen/nutzen + * + * UI-Empfehlung: Prüfe den Status über [LicenseStatusProvider.status], das + * als Compose-State vorliegt. Reagiert reaktiv auf Lizenzimport ohne + * App-Neustart. + */ +object LicenseFeatureAccess { + + /** + * Professionelle Lizenztypen (case-insensitive), die Profi-Zugang gewähren. + */ + private val PRO_LICENSE_TYPES = setOf("pro", "profi", "commercial", "developer", "trial") + + /** + * Prüft, ob die aktuelle Lizenz professionellen Zugang gewährt. + * + * Bedingungen: + * 1. [LicenseStatus.state] muss [LicenseStatus.State.VALID] sein. + * 2. [LicenseStatus.licenseTypeRaw] muss in [PRO_LICENSE_TYPES] enthalten sein + * (case-insensitive), ODER in [LicenseStatus.permissions] ist mindestens + * eine Profi-Permission gesetzt. + * 3. Developer-Lizenzen sind immer eingeschlossen, wenn state == VALID. + */ + fun hasProfessionalAccess(status: LicenseStatus): Boolean { + if (status.state != LicenseStatus.State.VALID) return false + val typeRaw = status.licenseTypeRaw?.lowercase()?.trim() ?: "" + if (typeRaw in PRO_LICENSE_TYPES) return true + // Fallback: explizite Permissions (z. B. "commercial_use" oder "paid_tours") + return status.permissions.any { (_, v) -> v } + } + + /** + * Shortcut: Liest den aktuellen Status direkt aus [LicenseStatusProvider]. + * Für Compose-Code bevorzuge den Status-Parameter, damit die Rekomposition + * reaktiv funktioniert. + */ + fun hasProfessionalAccessCurrent(): Boolean = + hasProfessionalAccess(LicenseStatusProvider.current()) + + /** + * Multi-Clip-Waypoints: Bearbeiten, Hinzufügen, Aktivieren von Zusatzclips. + * Hauptclip (index 0 / isPrimary) bleibt immer frei. + * Bestehende Clips werden niemals gelöscht. + */ + fun canUseMultiClip(status: LicenseStatus): Boolean = hasProfessionalAccess(status) + + /** + * PTT (Push-to-Talk): Live-PTT-Modul, Aufnahmen starten. + * Bestehende PTT-Aufnahmen bleiben erhalten. + */ + fun canUsePtt(status: LicenseStatus): Boolean = hasProfessionalAccess(status) + + /** + * Tour-Zähler: Abspiel-Modi, Zähler-Reset, Vorgaben. + * Bestehende Zählerwerte bleiben erhalten. + */ + fun canUseTourCounter(status: LicenseStatus): Boolean = hasProfessionalAccess(status) + + /** + * Freundlicher Hinweistext für das Multi-Clip-Gate. + */ + const val MSG_MULTI_CLIP = + "Multi-Clip-Waypoints sind Teil der GPS2Audio Profi-Lizenz." + + /** + * Freundlicher Hinweistext für das PTT-Gate. + */ + const val MSG_PTT = + "Live-PTT ist Teil der GPS2Audio Profi-Lizenz." + + /** + * Freundlicher Hinweistext für das Tour-Zähler-Gate. + */ + const val MSG_TOUR_COUNTER = + "Der Tour-Zähler ist Teil der GPS2Audio Profi-Lizenz." +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/LicenseStatus.kt b/app/src/main/kotlin/de/waypointaudio/license/LicenseStatus.kt new file mode 100644 index 0000000..c1e9412 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/LicenseStatus.kt @@ -0,0 +1,31 @@ +package de.waypointaudio.license + +/** + * v2.5.20 — Erweitertes Lizenzstatus-Datenmodell. + * + * v2.5.17 hat ein bewusst minimales String-only-Modell eingeführt, das die + * About-Card direkt rendern konnte. v2.5.20 ergänzt strukturierte Felder, + * damit die echte Offline-Lizenzprüfung Lizenztyp, Lizenznehmer, Gültigkeit, + * Permissions, Bindung und Lizenz-ID anzeigen kann. + * + * Bewusst unverändert bleiben die alten Felder ([stateText], [holderText], + * [validityText], [featuresText], [isPro]), damit die bisherige UI ohne + * Anpassung weiterläuft und der Card-Aufbau in [de.waypointaudio.ui.AboutDialog] + * stabil bleibt. + */ +data class LicenseStatus( + val stateText: String, + val holderText: String, + val validityText: String, + val featuresText: String, + val isPro: Boolean, + val state: State = State.NONE, + val licenseId: String? = null, + val licenseTypeRaw: String? = null, + val licenseTypeLabel: String? = null, + val bindingText: String? = null, + val permissions: Map = emptyMap(), + val errorMessage: String? = null, +) { + enum class State { NONE, VALID, INVALID, EXPIRED } +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/LicenseStatusProvider.kt b/app/src/main/kotlin/de/waypointaudio/license/LicenseStatusProvider.kt new file mode 100644 index 0000000..f114f1a --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/LicenseStatusProvider.kt @@ -0,0 +1,302 @@ +package de.waypointaudio.license + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import org.json.JSONObject + +/** + * v2.5.29 — Lizenzstatus-Anbieter: Update-Persistenz-Fix. + * + * Hintergrund des Bugs (gefunden in v2.5.29-Analyse): + * - LicenseStatusProvider ist ein Kotlin-object (Singleton), der beim Start + * eines neuen App-Prozesses frisch initialisiert wird — mit initialized=false. + * - Die primäre Lizenzdatei (filesDir/license/) überlebt APK-Updates, da + * Android App-Daten bei Updates NICHT löscht. + * - Dennoch wurde nach einem Update "Keine Lizenz importiert" angezeigt, weil: + * (a) ensureLoaded() nur in AboutDialog aufgerufen wird (LaunchedEffect) — + * also erst wenn der Nutzer den Über-Dialog öffnet. + * (b) ensureLoaded() setzt initialized=true BEVOR reload() läuft, was bei + * parallelen Aufrufen dazu führen kann, dass der zweite Aufruf das Laden + * überspringt (initialized=true nach erstem Aufruf, reload noch pending). + * (c) remember(reloadTrigger) { LicenseStatusProvider.status } liest den + * State synchron bei der ersten Komposition. Da ensureLoaded() noch nicht + * gelaufen ist, liefert es emptyStatus(). Nach dem LaunchedEffect-Reload + * aktualisiert sich der State, aber reloadTrigger hat sich nicht erhöht, + * sodass remember() den alten Wert cacht. + * + * Fixes in v2.5.29: + * 1. WaypointApp.onCreate() ruft LicenseStatusProvider.reloadBlocking(context) + * auf einem Hintergrundthread auf, sodass der Lizenzstatus beim ersten + * Compose-Render bereits korrekt gesetzt ist. + * 2. ensureLoaded() ist jetzt @Synchronized, sodass kein Race beim parallelen + * Aufruf passieren kann. + * 3. reloadBlocking() / reload() setzt initialized=true NACH dem Laden, nicht davor. + * Damit schützt das Flag korrekt vor doppeltem Laden ohne das Laden zu überspringen. + * 4. LicenseStorage.readRaw() hat eine Fallback-Logik über SharedPreferences: + * Falls die primäre Datei fehlt, wird sie aus einem Preferences-Eintrag + * wiederhergestellt (self-healing). Die Preferences werden beim Schreiben + * stets mitgepflegt und nur beim expliziten „Entfernen" gelöscht. + * + * Unverändertes Verhalten: + * - Import: funktionierender SAF-Picker, Dispatchers.IO, drei Lesestrategien. + * - Signaturprüfung: ECDSA-P256-SHA256, CanonicalJson, Public Key aus res/raw. + * - remove(): löscht Datei UND Preferences-Fallback, setzt initialized=false. + * - Backup-Ausschluss: bleibt in backup_rules.xml / data_extraction_rules.xml. + * HINWEIS: Backup-Ausschluss betrifft NICHT APK-Updates. APK-Updates lassen + * App-Daten grundsätzlich unangetastet. + * - Keine Feature-Gates aktiviert oder deaktiviert. + * + * v2.5.21: + * - initialized-Flag wird nach remove() zurückgesetzt. + * - import() liest Bytes immer frisch, kein gecachter URI-Zustand. + * + * v2.5.20: + * - Lizenzimport, Offline-Signaturprüfung, Anzeige im About-Dialog. + * + * Compose-Integration: der aktuelle Status liegt in einem MutableState, das + * von der About-Card direkt gelesen werden kann. + * + * WICHTIG: Es werden KEINE App-Funktionen abhängig vom Ergebnis aktiviert + * oder gesperrt. Der Provider dient ausschließlich der Anzeige. + */ +object LicenseStatusProvider { + + private val statusState = mutableStateOf(emptyStatus()) + + // v2.5.29: @Volatile + initialized wird erst NACH dem Laden auf true gesetzt. + @Volatile private var initialized = false + + val status: LicenseStatus get() = statusState.value + + /** + * Stellt sicher, dass die Lizenz aus dem persistenten Speicher geladen ist. + * + * v2.5.29 — @Synchronized: verhindert Race-Conditions bei parallelen Aufrufen + * (z. B. aus LaunchedEffect und WaypointApp.onCreate gleichzeitig). + * initialized wird erst NACH dem Laden auf true gesetzt. + */ + @Synchronized + fun ensureLoaded(context: Context) { + if (initialized) return + reload(context) + initialized = true + } + + /** + * Liest die Lizenzdatei aus dem persistenten Speicher und aktualisiert den Status. + * Kann jederzeit aufgerufen werden (z. B. nach Import oder als Startup-Init). + * + * v2.5.29 — Diese Methode ist unverändert gegenüber v2.5.21 und kann sicher + * aus einem Hintergrundthread aufgerufen werden. Das MutableState-Update ist + * Thread-safe (Compose-State erlaubt Schreiben von beliebigen Threads; + * Compose rekomponiert auf dem Hauptthread beim nächsten Frame). + */ + fun reload(context: Context) { + val raw = LicenseStorage.readRaw(context) + statusState.value = if (raw == null) { + emptyStatus() + } else { + buildStatus(LicenseValidator.verifyOffline(context, raw.toByteArray(Charsets.UTF_8))) + } + } + + /** + * Snapshot-Variante für Aufrufer, die nicht in Compose laufen oder den + * Pre-Compose-Default (v2.5.17-kompatibel) benötigen. + */ + fun current(): LicenseStatus = statusState.value + + /** + * Versucht, eine Lizenzdatei zu importieren. + * + * v2.5.29 — initialized wird beim Import explizit auf true gesetzt (wie v2.5.21). + * + * v2.5.21 — Wichtige Verhaltensänderung: + * - Die übergebenen [fileBytes] werden IMMER frisch ausgewertet. + * - [initialized] bleibt nach diesem Aufruf true (ist bereits geladen). + * - Fehlerbehandlung: alle Exception-Pfade werden abgefangen. + * + * - Bei gültiger Lizenz: speichert die Datei lokal und aktualisiert den Status. + * - Bei ungültiger/abgelaufener Lizenz: aktualisiert den Status NICHT, + * falls bereits eine gültige Lizenz vorhanden ist. + */ + fun import(context: Context, fileBytes: ByteArray): ImportResult { + // initialized hier explizit auf true setzen, damit ensureLoaded() danach + // kein erneutes Laden überschreibt, das einen alten Stand zurückbringt. + initialized = true + return try { + val result = LicenseValidator.verifyOffline(context, fileBytes) + when (result.state) { + LicenseStatus.State.VALID -> { + val raw = result.rawJson ?: String(fileBytes, Charsets.UTF_8) + val stored = LicenseStorage.writeRaw(context, raw) + if (stored) { + statusState.value = buildStatus(result) + ImportResult(true, "Lizenz erfolgreich importiert") + } else { + // v2.5.29: writeRaw schreibt Fallback-Preferences auch bei + // Datei-Fehler. Status trotzdem aktualisieren, da Fallback greift. + statusState.value = buildStatus(result) + ImportResult(true, "Lizenz importiert (Fallback-Speicher)") + } + } + else -> { + val keepExisting = statusState.value.state == LicenseStatus.State.VALID + if (!keepExisting) { + statusState.value = buildStatus(result) + } + ImportResult(false, result.message ?: "Lizenz ungültig") + } + } + } catch (e: Throwable) { + ImportResult(false, "Unerwarteter Fehler beim Import: ${e.message}") + } + } + + /** + * Entfernt die gespeicherte Lizenz und setzt den Zustand vollständig zurück. + * + * v2.5.29: LicenseStorage.delete() löscht jetzt auch den Fallback in den + * SharedPreferences, sodass die Lizenz nach dem Entfernen wirklich weg ist — + * auch wenn der Fallback-Pfad greifen würde. + * + * v2.5.21 fix: initialized wird auf false gesetzt, damit ein nachfolgender + * Import-Aufruf in einem sauberen Zustand startet. + */ + fun remove(context: Context): Boolean { + val ok = LicenseStorage.delete(context) + if (ok) { + statusState.value = emptyStatus() + // Reset initialized so the next ensureLoaded() re-reads from disk + // (which will now correctly show no license). + initialized = false + } + return ok + } + + fun exportRawJson(context: Context): String? = LicenseStorage.readRaw(context) + + fun hasStoredLicense(context: Context): Boolean = LicenseStorage.exists(context) + + private fun emptyStatus(): LicenseStatus = LicenseStatus( + stateText = "Keine Lizenz importiert", + holderText = "-", + validityText = "-", + featuresText = "Basisfunktionen", + isPro = false, + state = LicenseStatus.State.NONE, + ) + + private fun buildStatus(result: LicenseValidator.Result): LicenseStatus { + val payload = result.payload + if (payload == null) { + return LicenseStatus( + stateText = "Ungültige Lizenz", + holderText = "-", + validityText = "-", + featuresText = "Basisfunktionen", + isPro = false, + state = result.state, + errorMessage = result.message, + ) + } + + val typeRaw = payload.optString("license_type", "") + val typeLabel = humanLicenseType(typeRaw) + val holder = humanHolder(payload.optJSONObject("customer")) + val validity = humanValidity(payload.opt("valid_until")) + val permissions = readPermissions(payload.optJSONObject("permissions")) + val featuresText = humanFeatures(permissions) + val stateText = when (result.state) { + LicenseStatus.State.VALID -> "Gültige Lizenz" + LicenseStatus.State.EXPIRED -> "Lizenz abgelaufen" + LicenseStatus.State.INVALID -> "Ungültige Lizenz" + LicenseStatus.State.NONE -> "Keine Lizenz importiert" + } + val isPro = result.state == LicenseStatus.State.VALID && typeRaw.isNotEmpty() + return LicenseStatus( + stateText = stateText, + holderText = holder, + validityText = validity, + featuresText = featuresText, + isPro = isPro, + state = result.state, + licenseId = payload.optString("license_id", "").ifEmpty { null }, + licenseTypeRaw = typeRaw.ifEmpty { null }, + licenseTypeLabel = typeLabel, + bindingText = humanBinding(payload.optJSONObject("binding")), + permissions = permissions, + errorMessage = if (result.state == LicenseStatus.State.VALID) null else result.message, + ) + } + + private fun humanLicenseType(raw: String): String? = when (raw) { + "" -> null + "commercial", "pro", "profi" -> "Profi" + "developer" -> "Developer" + "trial" -> "Trial" + else -> raw.replaceFirstChar { it.uppercase() } + } + + private fun humanHolder(customer: JSONObject?): String { + if (customer == null) return "-" + val company = customer.optString("company", "").trim() + if (company.isNotEmpty() && company != "null") return company + val name = customer.optString("name", "").trim() + if (name.isNotEmpty() && name != "null") return name + val email = customer.optString("email", "").trim() + return if (email.isNotEmpty() && email != "null") email else "-" + } + + private fun humanValidity(value: Any?): String { + if (value == null || value === JSONObject.NULL) return "Unbefristet" + val str = value.toString().trim() + if (str.isEmpty() || str == "null") return "Unbefristet" + val datePart = str.substringBefore('T') + return datePart.ifEmpty { str } + } + + private fun humanBinding(binding: JSONObject?): String { + if (binding == null) return "Keine Bindung" + return when (binding.optString("type", "none")) { + "none" -> "Keine Bindung" + "device" -> "Geräte-Bindung (1 Gerät)" + "devices" -> { + val max = binding.optInt("max_devices", -1) + if (max > 0) "Geräte-Bindung (max. $max Geräte)" else "Geräte-Bindung" + } + "organization" -> { + val org = binding.optString("organization_name", "").trim() + if (org.isNotEmpty() && org != "null") "Organisation: $org" else "Organisations-Bindung" + } + else -> "Unbekannte Bindung" + } + } + + private fun readPermissions(perms: JSONObject?): Map { + if (perms == null) return emptyMap() + val out = linkedMapOf() + val keys = perms.keys() + while (keys.hasNext()) { + val k = keys.next() + out[k] = perms.optBoolean(k, false) + } + return out + } + + private fun humanFeatures(permissions: Map): String { + val active = permissions.filterValues { it }.keys + if (active.isEmpty()) return "Basisfunktionen" + return active.joinToString(", ") { humanPermissionLabel(it) } + } + + fun humanPermissionLabel(key: String): String = when (key) { + "commercial_use" -> "Kommerzielle Nutzung" + "paid_tours" -> "Bezahlte Touren" + "internal_testing" -> "Internes Testen" + "redistribution" -> "Weitergabe" + else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + + data class ImportResult(val success: Boolean, val message: String) +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/LicenseStorage.kt b/app/src/main/kotlin/de/waypointaudio/license/LicenseStorage.kt new file mode 100644 index 0000000..21c895a --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/LicenseStorage.kt @@ -0,0 +1,148 @@ +package de.waypointaudio.license + +import android.content.Context +import android.content.SharedPreferences +import java.io.File + +/** + * v2.5.29 — Persistente, app-interne Ablage der importierten Lizenzdatei. + * + * Speicherorte (primär + Fallback): + * + * 1. PRIMÄR: `filesDir/license/gps2audio.license.json` + * `filesDir` ist app-privat (`/data/data//files`). Diese Datei + * überlebt normale APK-Updates, weil Android-App-Updates die App-Daten + * (filesDir, SharedPreferences usw.) NICHT löschen. Sie wird nur beim + * expliziten „App-Daten löschen" oder bei der Deinstallation entfernt. + * + * 2. FALLBACK: SharedPreferences `gps2audio_license_store` + * Beim Schreiben wird der Roh-JSON-Inhalt zusätzlich als String in den + * SharedPreferences gespeichert. Beim Lesen: wenn die primäre Datei + * fehlt (z. B. nach seltener Dateisystem-Anomalie), aber der Preferences- + * Eintrag vorhanden ist, wird die Datei aus den Preferences wiederhergestellt. + * Diese zweite Kopie dient ausschließlich als Ausfallsicherung — sie wird + * gelöscht, wenn der Nutzer die Lizenz explizit entfernt. + * + * Backup-Ausschluss (unverändert): + * - `license/`-Verzeichnis und `gps2audio_license_store.xml` sind in + * `backup_rules.xml` (Android < 12) und `data_extraction_rules.xml` + * (Android 12+) vom automatischen Cloud-Backup ausgeschlossen. + * - WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup + * (Google Drive). Er NICHT bewirkt, dass beim APK-Update Daten gelöscht + * werden. APK-Updates lassen App-Daten grundsätzlich unangetastet. + * + * Nur EINE Lizenz wird gespeichert. Beim Import wird die alte Datei atomar + * ersetzt — aber nur, wenn die neue Lizenz gültig ist (siehe LicenseStatusProvider.import). + */ +internal object LicenseStorage { + + private const val DIR_NAME = "license" + private const val FILE_NAME = "gps2audio.license.json" + + // v2.5.29 — Fallback-SharedPreferences für doppelte Ablage des Lizenz-JSON. + private const val PREFS_STORE = "gps2audio_license_store" + private const val KEY_RAW_JSON = "raw_json" + + // --------------------------------------------------------------------------- + // Primäre Datei-Operationen + // --------------------------------------------------------------------------- + + fun file(context: Context): File { + val dir = File(context.filesDir, DIR_NAME) + if (!dir.exists()) dir.mkdirs() + return File(dir, FILE_NAME) + } + + /** + * Liest den Lizenz-JSON-Rohtext. + * + * v2.5.29 — Fallback-Logik: + * 1. Primäre Datei lesen. + * 2. Falls Datei fehlt oder leer: SharedPreferences-Fallback prüfen. + * 3. Falls Fallback vorhanden: Datei daraus wiederherstellen und zurückgeben. + * So ist nach dem nächsten Zugriff wieder die primäre Datei da. + */ + fun readRaw(context: Context): String? { + // 1. Primäre Datei + val f = file(context) + if (f.isFile) { + val content = try { + f.readText(Charsets.UTF_8).trim() + } catch (_: Throwable) { + null + } + if (!content.isNullOrEmpty()) return content + } + + // 2. Fallback: SharedPreferences + val fromPrefs = prefs(context).getString(KEY_RAW_JSON, null) + if (!fromPrefs.isNullOrEmpty()) { + // 3. Datei aus Preferences wiederherstellen (self-healing) + try { + val tmp = File(f.parentFile ?: file(context).parentFile!!, f.name + ".tmp") + tmp.writeText(fromPrefs, Charsets.UTF_8) + if (f.exists()) f.delete() + tmp.renameTo(f) + } catch (_: Throwable) { + // Wiederherstellung gescheitert, Preferences-Wert trotzdem zurückgeben + } + return fromPrefs + } + + return null + } + + /** + * Schreibt den Lizenz-JSON-Rohtext atomar in die primäre Datei + * und zusätzlich in die Fallback-SharedPreferences. + */ + fun writeRaw(context: Context, raw: String): Boolean { + return try { + val f = file(context) + val tmp = File(f.parentFile, f.name + ".tmp") + tmp.writeText(raw, Charsets.UTF_8) + if (f.exists()) f.delete() + val renamed = tmp.renameTo(f) + // v2.5.29 — Fallback-Preferences immer schreiben, unabhängig vom Datei-Ergebnis + prefs(context).edit().putString(KEY_RAW_JSON, raw).apply() + renamed + } catch (_: Throwable) { + // Wenn Datei-Schreiben fehlschlägt, trotzdem Preferences versuchen + try { + prefs(context).edit().putString(KEY_RAW_JSON, raw).apply() + } catch (_: Throwable) { /* ignore */ } + false + } + } + + /** + * Löscht die Lizenz vollständig: primäre Datei UND Fallback-Preferences. + * Wird nur beim expliziten Nutzer-Klick auf „Lizenz entfernen" aufgerufen. + */ + fun delete(context: Context): Boolean { + return try { + val f = file(context) + val fileDeleted = if (f.exists()) f.delete() else true + // v2.5.29 — Fallback-Preferences ebenfalls löschen + prefs(context).edit().remove(KEY_RAW_JSON).apply() + fileDeleted + } catch (_: Throwable) { + false + } + } + + /** + * Prüft ob eine Lizenz vorhanden ist (primäre Datei ODER Fallback). + */ + fun exists(context: Context): Boolean { + if (file(context).isFile) return true + return !prefs(context).getString(KEY_RAW_JSON, null).isNullOrEmpty() + } + + // --------------------------------------------------------------------------- + // Hilfsmethoden + // --------------------------------------------------------------------------- + + private fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE) +} diff --git a/app/src/main/kotlin/de/waypointaudio/license/LicenseValidator.kt b/app/src/main/kotlin/de/waypointaudio/license/LicenseValidator.kt new file mode 100644 index 0000000..233089b --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/license/LicenseValidator.kt @@ -0,0 +1,180 @@ +package de.waypointaudio.license + +import android.content.Context +import android.util.Base64 +import de.waypointaudio.R +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.security.KeyFactory +import java.security.PublicKey +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * v2.5.20 — Echte Offline-Lizenzprüfung. + * + * v2.5.17 hat lediglich den Public Key als Raw-Resource eingebettet und ein + * Skelett für die spätere Prüfung bereitgestellt. v2.5.20 implementiert nun: + * - Lesen einer `.gps2audio-license`-Datei (JSON). + * - Strukturelle Prüfung (`schema_version`, `payload`, `product`, Signatur- + * Algorithmus). + * - ECDSA-P256-SHA256-Verifikation gegen den eingebetteten Public Key. + * - Gültigkeitsprüfung (`valid_until` falls gesetzt). + * + * Die Methode [verifyOffline] gibt ein [Result]-Objekt mit dem Ergebnis + * zurück. Sie wirft NIEMALS — auch nicht bei ungültigem JSON oder fehlender + * Resource — sondern liefert ein passendes Fehlerergebnis. + * + * WICHTIG: Diese Prüfung beeinflusst KEINE App-Funktion. Sie wird nur für + * die Anzeige im Lizenzbereich verwendet (siehe [LicenseStatusProvider]). + * Der Private Key bleibt ausschliesslich beim Ersteller. + */ +object LicenseValidator { + + private const val SCHEMA_VERSION = "gps2audio-license-v1" + private const val SIGNATURE_ALGORITHM = "ECDSA_P256_SHA256" + private const val PRODUCT_NAME = "GPS2Audio" + + data class Result( + val state: LicenseStatus.State, + val payload: JSONObject? = null, + val rawJson: String? = null, + val message: String? = null, + ) { + val isValid: Boolean get() = state == LicenseStatus.State.VALID + } + + fun readEmbeddedPublicKeyPem(context: Context): String? = try { + context.resources.openRawResource(R.raw.gps2audio_license_public).use { input -> + BufferedReader(InputStreamReader(input, Charsets.UTF_8)).readText() + } + } catch (_: Throwable) { + null + } + + /** + * Prüft den übergebenen Lizenz-Dateiinhalt offline. + * + * Reihenfolge der Checks: + * 1. JSON parsen. + * 2. `schema_version` muss "gps2audio-license-v1" sein. + * 3. `payload.product` muss "GPS2Audio" sein. + * 4. `signature.algorithm` muss "ECDSA_P256_SHA256" sein. + * 5. ECDSA-Verifikation gegen den eingebetteten Public Key. + * 6. `payload.valid_until` (falls vorhanden) darf nicht in der + * Vergangenheit liegen. + */ + fun verifyOffline(context: Context, licenseFileBytes: ByteArray): Result { + val raw = try { + String(licenseFileBytes, Charsets.UTF_8) + } catch (_: Throwable) { + return Result(LicenseStatus.State.INVALID, message = "Datei ist kein gültiges UTF-8") + } + + val doc = try { + JSONObject(raw) + } catch (_: Throwable) { + return Result(LicenseStatus.State.INVALID, message = "Lizenzdatei ist kein gültiges JSON") + } + + val schemaVersion = doc.optString("schema_version", "") + val payload = doc.optJSONObject("payload") + val signatureObj = doc.optJSONObject("signature") + if (schemaVersion != SCHEMA_VERSION || payload == null) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Lizenzformat") + } + + val product = payload.optString("product", "") + if (product != PRODUCT_NAME) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Falsches Produkt: $product") + } + + val algorithm = signatureObj?.optString("algorithm", "") ?: "" + if (algorithm != SIGNATURE_ALGORITHM) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Unbekannter Signaturalgorithmus") + } + + val signatureValue = signatureObj.optString("value", "") + val signatureBytes = try { + Base64.decode(signatureValue, Base64.DEFAULT) + } catch (_: Throwable) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ist kein gültiges Base64") + } + + val publicKey = loadEmbeddedPublicKey(context) + ?: return Result(LicenseStatus.State.INVALID, payload, raw, "Eingebetteter Public Key fehlt") + + val signedContent = JSONObject().apply { + put("schema_version", schemaVersion) + put("payload", payload) + } + val canonical = CanonicalJson.encode(signedContent) + + val signatureValid = try { + val sig = Signature.getInstance("SHA256withECDSA") + sig.initVerify(publicKey) + sig.update(canonical.toByteArray(Charsets.UTF_8)) + sig.verify(signatureBytes) + } catch (_: Throwable) { + false + } + if (!signatureValid) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ungültig") + } + + val validUntilRaw = payload.optString("valid_until", "") + val validUntilNonNull = if (payload.isNull("valid_until")) null else validUntilRaw.ifEmpty { null } + if (validUntilNonNull != null) { + val expiry = parseIsoDate(validUntilNonNull) + if (expiry == null) { + return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Ablaufdatum") + } + if (expiry.before(Date())) { + return Result(LicenseStatus.State.EXPIRED, payload, raw, "Lizenz abgelaufen") + } + } + + return Result(LicenseStatus.State.VALID, payload, raw, "Lizenz ist gültig") + } + + private fun loadEmbeddedPublicKey(context: Context): PublicKey? { + val pem = readEmbeddedPublicKeyPem(context) ?: return null + return try { + val base64 = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\\s".toRegex(), "") + val der = Base64.decode(base64, Base64.DEFAULT) + val spec = X509EncodedKeySpec(der) + KeyFactory.getInstance("EC").generatePublic(spec) + } catch (_: Throwable) { + null + } + } + + private fun parseIsoDate(value: String): Date? { + val cleaned = value.trim().let { + if (it.endsWith("Z")) it.dropLast(1) + "+0000" else it.replace(Regex("([+-]\\d{2}):(\\d{2})$"), "$1$2") + } + val formats = listOf( + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "yyyy-MM-dd", + ) + for (pattern in formats) { + try { + val sdf = SimpleDateFormat(pattern, Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + return sdf.parse(cleaned) + } catch (_: Throwable) { + // try next + } + } + return null + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt index a065223..255124c 100644 --- a/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt +++ b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt @@ -2,7 +2,10 @@ package de.waypointaudio.repository import android.content.Context import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.TourCover import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.TourRoute +import de.waypointaudio.data.TrackDraft import de.waypointaudio.data.Waypoint import de.waypointaudio.data.WaypointStore import kotlinx.coroutines.flow.Flow @@ -27,6 +30,43 @@ class WaypointRepository(context: Context) { /** Reaktiver Flow der gespeicherten GPS-Tracks. */ val gpsTracks: Flow>> = store.gpsTracksFlow + /** + * v2.4.6 — Reaktiver Flow der persistierten Tourrouten (Map tourName → TourRoute). + * Fehlt der Eintrag, gibt es für die Tour keine gespeicherte Route. + */ + val tourRoutes: Flow> = store.tourRoutesFlow + + /** Speichert (oder ersetzt) die Route einer Tour. */ + suspend fun saveTourRoute(tourName: String, route: TourRoute) = + store.saveTourRoute(tourName, route) + + /** Entfernt die gespeicherte Route einer Tour (kein Fehler, wenn keine vorhanden). */ + suspend fun deleteTourRoute(tourName: String) = store.deleteTourRoute(tourName) + + /** Tour-Umbenennung: Route-Eintrag mitziehen. */ + suspend fun renameTourRouteKey(oldName: String, newName: String) = + store.renameTourRouteKey(oldName, newName) + + /** Bulk-Merge für Backup-Import. */ + suspend fun mergeTourRoutes(routes: Map) = store.mergeTourRoutes(routes) + + /** v2.5.5 — Reaktiver Flow der persistierten Tour-Cover (Map tourName → TourCover). */ + val tourCovers: Flow> = store.tourCoversFlow + + /** v2.5.5 — Speichert/ersetzt das Cover einer Tour. */ + suspend fun saveTourCover(tourName: String, cover: TourCover) = + store.saveTourCover(tourName, cover) + + /** v2.5.5 — Entfernt das Cover einer Tour (kein Fehler, wenn keins gesetzt). */ + suspend fun deleteTourCover(tourName: String) = store.deleteTourCover(tourName) + + /** v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen. */ + suspend fun renameTourCoverKey(oldName: String, newName: String) = + store.renameTourCoverKey(oldName, newName) + + /** v2.5.5 — Bulk-Merge für Backup-Import. */ + suspend fun mergeTourCovers(covers: Map) = store.mergeTourCovers(covers) + /** Wegpunkt hinzufügen oder aktualisieren. */ suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint) @@ -73,4 +113,18 @@ class WaypointRepository(context: Context) { /** Löscht den GPS-Track einer Tour. */ suspend fun clearGpsTrack(tourName: String) = store.clearGpsTrack(tourName) + + // ─── Track-Drafts ───────────────────────────────────────────────────────── + + /** Reaktiver Flow aller archivierten Track-Drafts. */ + val trackDrafts: Flow> = store.trackDraftsFlow + + /** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */ + suspend fun addTrackDraft(draft: TrackDraft) = store.addTrackDraft(draft) + + /** Aktualisiert (oder fügt hinzu) einen Track-Draft. */ + suspend fun upsertTrackDraft(draft: TrackDraft) = store.upsertTrackDraft(draft) + + /** Löscht einen Track-Draft. */ + suspend fun deleteTrackDraft(id: String) = store.deleteTrackDraft(id) } diff --git a/app/src/main/kotlin/de/waypointaudio/service/AtmoPlaybackService.kt b/app/src/main/kotlin/de/waypointaudio/service/AtmoPlaybackService.kt new file mode 100644 index 0000000..8acc8a8 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/AtmoPlaybackService.kt @@ -0,0 +1,737 @@ +package de.waypointaudio.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import de.waypointaudio.MainActivity +import de.waypointaudio.R +import de.waypointaudio.data.MusicSourceType +import de.waypointaudio.data.PlaylistItem +import de.waypointaudio.data.StreamEntry +import de.waypointaudio.data.TourAudioSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Vordergrund-Dienst, der die Begleitmusik (Atmo) im Hintergrund am Leben hält. + * + * v2.0.7 Architektur: + * ==================== + * Der Service besitzt jetzt den **ExoPlayer** direkt – nicht nur die Notification. + * + * Hintergrund: In v2.0.4–v2.0.6 lief die Wiedergabe in einem App-Singleton + * (`BackgroundMusicPlayer`), das nur einen "Shell"-Foreground-Service nutzte. + * Auf Android 12+/14 hat das nicht zuverlässig funktioniert: die OS-Heuristiken + * koppeln aktive Hintergrund-Wiedergabe an einen Service mit Typ + * `mediaPlayback`, der die Audio-Pipeline auch wirklich besitzt. Andernfalls + * kann der Prozess in "cached" wechseln und der Player wird angehalten – auch + * mit gesetztem `setWakeMode()`. + * + * Mit v2.0.7: + * - Service hält ExoPlayer-Instanz von [onStartCommand] bis [onDestroy]. + * - Sämtliche play/pause/stop/next/prev/volume Anweisungen werden via Intent- + * Action an den Service geschickt. + * - Service exponiert einen process-singleton State-Flow, den UI / ViewModel + * direkt beobachten. + * - [BackgroundMusicPlayer] ist nur noch ein dünner Facade-Wrapper, der + * die bestehende öffentliche API erhält. + * + * Lifecycle / Lebensdauer: + * - Service startet wenn Atmo erstmalig Wiedergabe beginnt (`play()`). + * - Service läuft **ununterbrochen** weiter durch: + * - Pause/Resume durch PTT / Wegpunkt-Audio / manuellen Player + * - App-Wechsel in den Hintergrund (Home, Recents) + * - Bildschirm aus + * - System-Doze (durch FGS + wakeMode geschützt) + * - Service stoppt sich erst bei explizitem [ACTION_STOP] (User stoppt + * Begleitmusik, Tour wird gewechselt mit deaktivierter Atmo, App wird + * beendet via `BackgroundMusicManager.release()`). + * + * Crash-Schutz: + * - `startForeground` wird in `onCreate` als allererstes mit einer + * minimal-validen Notification aufgerufen (5s System-Frist). + * - Wenn POST_NOTIFICATIONS verweigert ist, schlägt `startForeground` nicht + * fehl (Android zeigt nur keine Notification an – Service bleibt aktiv). + * - Exceptions in onStartCommand werden geloggt, brechen aber nicht den + * Service ab. Auch null-Intent (Re-Delivery) lässt den Service weiterlaufen. + * + * Audio-Fokus: + * - `handleAudioFocus = false` – die Fokus-/Pause-/Resume-Logik bei + * Unterbrechungen (PTT, Wegpunkt-Audio, manueller Player) regelt + * [AtmoResumeManager] auf Anwendungsebene. + */ +class AtmoPlaybackService : Service() { + + companion object { + const val ACTION_START = "de.waypointaudio.atmo.ACTION_START" + const val ACTION_STOP = "de.waypointaudio.atmo.ACTION_STOP" + const val ACTION_PAUSE = "de.waypointaudio.atmo.ACTION_PAUSE" + const val ACTION_RESUME = "de.waypointaudio.atmo.ACTION_RESUME" + const val ACTION_NEXT = "de.waypointaudio.atmo.ACTION_NEXT" + const val ACTION_PREVIOUS = "de.waypointaudio.atmo.ACTION_PREVIOUS" + const val ACTION_SET_VOLUME = "de.waypointaudio.atmo.ACTION_SET_VOLUME" + const val ACTION_UPDATE_SETTINGS = "de.waypointaudio.atmo.ACTION_UPDATE_SETTINGS" + + const val EXTRA_VOLUME = "atmo_volume" + const val EXTRA_IS_STREAM = "atmo_is_stream" + const val EXTRA_STREAM_URL = "atmo_stream_url" + const val EXTRA_STREAM_URLS = "atmo_stream_urls" + const val EXTRA_STREAM_NAMES = "atmo_stream_names" + const val EXTRA_PLAYLIST_URIS = "atmo_playlist_uris" + const val EXTRA_PLAYLIST_NAMES = "atmo_playlist_names" + const val EXTRA_SHUFFLE = "atmo_shuffle" + const val EXTRA_TITLE = "atmo_title" + const val EXTRA_SOURCE = "atmo_source" + + private const val TAG = "AtmoPlaybackService" + private const val NOTIFICATION_ID = 1101 + const val CHANNEL_ID = "atmo_playback_channel" + + // ─── Process-singleton state-flow ──────────────────────────────────── + // + // Der State-Flow existiert auch, wenn der Service gerade nicht läuft – + // dann enthält er nur den leeren MusicPlaybackState. So muss die UI + // nicht zwischen "Service nicht da" und "Service läuft" unterscheiden. + private val _state = MutableStateFlow(MusicPlaybackState()) + val state: StateFlow = _state.asStateFlow() + + internal fun publishStateExternal(s: MusicPlaybackState) { + _state.value = s + } + + // ─── Companion API used by BackgroundMusicPlayer / Manager ─────────── + + /** + * Startet den Service mit der gegebenen Quelle. Wenn der Service bereits + * läuft, wird die Wiedergabe an die neue Quelle angepasst (Player wird + * intern neu aufgebaut). + */ + fun start(context: Context, settings: TourAudioSettings) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_START + putSettings(settings) + putExtra(EXTRA_TITLE, titleFor(settings)) + putExtra(EXTRA_SOURCE, sourceFor(settings)) + } + sendIntent(context, intent) + } + + fun updateSettings(context: Context, settings: TourAudioSettings) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_UPDATE_SETTINGS + putSettings(settings) + putExtra(EXTRA_TITLE, titleFor(settings)) + putExtra(EXTRA_SOURCE, sourceFor(settings)) + } + sendIntent(context, intent) + } + + /** + * Vollständiger Stop (User hat Atmo gestoppt, Tour gewechselt, etc.). + * Service beendet sich danach selbst. + */ + fun stop(context: Context) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_STOP + } + sendIntent(context, intent) + } + + /** Temporäre Pause (PTT, Wegpunkt-Audio). Service bleibt aktiv. */ + fun pause(context: Context) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_PAUSE + } + sendIntent(context, intent) + } + + /** Resume nach Pause (PTT-Ende, Wegpunkt-Ende). */ + fun resume(context: Context) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_RESUME + } + sendIntent(context, intent) + } + + fun next(context: Context) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_NEXT + } + sendIntent(context, intent) + } + + fun previous(context: Context) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_PREVIOUS + } + sendIntent(context, intent) + } + + fun setVolume(context: Context, volume: Float) { + val intent = Intent(context, AtmoPlaybackService::class.java).apply { + action = ACTION_SET_VOLUME + putExtra(EXTRA_VOLUME, volume) + } + sendIntent(context, intent) + } + + private fun Intent.putSettings(settings: TourAudioSettings) { + val isStream = settings.sourceType == MusicSourceType.STREAM_URL + putExtra(EXTRA_IS_STREAM, isStream) + if (isStream) { + val entries = settings.effectiveStreamPlaylist + val urls = entries.map { it.url }.toTypedArray() + val names = entries.map { it.name }.toTypedArray() + putExtra(EXTRA_STREAM_URLS, urls) + putExtra(EXTRA_STREAM_NAMES, names) + // Legacy-Feld weiterhin mitschicken (Erstes Element), damit ältere + // Re-Delivery-Intents nicht ins Leere laufen. + putExtra(EXTRA_STREAM_URL, entries.firstOrNull()?.url.orEmpty()) + } else { + val uris = settings.localPlaylist.map { it.uriString }.toTypedArray() + val names = settings.localPlaylist.map { it.displayName }.toTypedArray() + putExtra(EXTRA_PLAYLIST_URIS, uris) + putExtra(EXTRA_PLAYLIST_NAMES, names) + } + putExtra(EXTRA_SHUFFLE, settings.shuffle) + } + + private fun titleFor(settings: TourAudioSettings): String = when (settings.sourceType) { + MusicSourceType.STREAM_URL -> "Stream" + MusicSourceType.LOCAL_PLAYLIST -> "Playlist" + } + + private fun sourceFor(settings: TourAudioSettings): String = when (settings.sourceType) { + MusicSourceType.STREAM_URL -> { + val entries = settings.effectiveStreamPlaylist + when { + entries.isEmpty() -> "" + entries.size == 1 -> { + val url = entries.first().url + runCatching { java.net.URL(url).host.ifBlank { url } }.getOrElse { url } + } + else -> "${entries.size} Streams" + } + } + MusicSourceType.LOCAL_PLAYLIST -> "${settings.localPlaylist.size} Titel" + } + + private fun sendIntent(context: Context, intent: Intent) { + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + }.onFailure { e -> + Log.w(TAG, "Konnte Intent ${intent.action} nicht zustellen", e) + } + } + } + + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var progressJob: Job? = null + + private var player: ExoPlayer? = null + private var isStream: Boolean = false + private var loadedPlaylist: List = emptyList() + private var loadedStreamEntries: List = emptyList() + private var streamUrl: String = "" + private var normalVolume: Float = 1.0f + private var foregroundStarted: Boolean = false + + private var currentTitle: String = "" + private var currentSource: String = "" + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + override fun onCreate() { + super.onCreate() + createChannel() + promoteToForeground(buildNotification("", "")) + Log.i(TAG, "AtmoPlaybackService erstellt") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!foregroundStarted) { + promoteToForeground(buildNotification(currentTitle, currentSource)) + } + + when (intent?.action) { + ACTION_START -> handleStart(intent) + ACTION_UPDATE_SETTINGS -> handleUpdateSettings(intent) + ACTION_STOP -> handleStop() + ACTION_PAUSE -> handlePause() + ACTION_RESUME -> handleResume() + ACTION_NEXT -> handleNext() + ACTION_PREVIOUS -> handlePrevious() + ACTION_SET_VOLUME -> handleSetVolume(intent.getFloatExtra(EXTRA_VOLUME, 1.0f)) + else -> { + // Null intent kann passieren bei Re-Delivery / System-Restart. + // Wir lassen den Service in dem Fall **am Leben** (Player intakt) – + // andernfalls würden wir laufende Wiedergabe killen. + Log.d(TAG, "onStartCommand mit null/unbekannter Action: ${intent?.action}") + } + } + + // START_STICKY: Wenn der Prozess vom System gekillt wird (was bei FGS + // selten passiert), versucht Android den Service wiederherzustellen. + // Anders als v2.0.6 (START_NOT_STICKY) verlieren wir damit nicht den + // Anker für die Hintergrund-Wiedergabe. + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + Log.i(TAG, "AtmoPlaybackService onDestroy – Player wird freigegeben") + progressJob?.cancel() + runCatching { + player?.stop() + player?.release() + } + player = null + mainScope.cancel() + publishStateExternal(MusicPlaybackState()) + stopForegroundCompat() + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + // Wenn der Nutzer die App aus den Recents wischt, lassen wir die + // Wiedergabe explizit weiterlaufen – das ist genau das Verhalten, + // das die User von einem Musik-Player erwarten. Sie können in der + // Notification weiterhin stoppen. + super.onTaskRemoved(rootIntent) + } + + // ─── Action handlers ─────────────────────────────────────────────────────── + + private fun handleStart(intent: Intent) { + applySettingsFromIntent(intent, startPlayback = true) + } + + private fun handleUpdateSettings(intent: Intent) { + val newIsStream = intent.getBooleanExtra(EXTRA_IS_STREAM, false) + val newStreamUrls = readStreamUrls(intent) + val newUris = intent.getStringArrayExtra(EXTRA_PLAYLIST_URIS).orEmpty().toList() + val newShuffle = intent.getBooleanExtra(EXTRA_SHUFFLE, false) + + val sameSource = (isStream == newIsStream) && when { + newIsStream -> loadedStreamEntries.map { it.url } == newStreamUrls + else -> loadedPlaylist.map { it.uriString } == newUris + } + + val currentlyPlaying = player?.isPlaying == true + if (!sameSource) { + applySettingsFromIntent(intent, startPlayback = currentlyPlaying) + } else { + player?.shuffleModeEnabled = newShuffle + currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() + currentSource = intent.getStringExtra(EXTRA_SOURCE).orEmpty() + refreshNotification() + publishCurrentState() + } + } + + /** + * Liest die Stream-URLs aus dem Intent. Bevorzugt das neue Array (`EXTRA_STREAM_URLS`), + * fällt auf das Legacy-Feld (`EXTRA_STREAM_URL`) zurück. + */ + private fun readStreamUrls(intent: Intent): List { + val arr = intent.getStringArrayExtra(EXTRA_STREAM_URLS) + if (!arr.isNullOrEmpty()) return arr.toList() + val legacy = intent.getStringExtra(EXTRA_STREAM_URL).orEmpty() + return if (legacy.isNotBlank()) listOf(legacy) else emptyList() + } + + private fun readStreamNames(intent: Intent): List { + val arr = intent.getStringArrayExtra(EXTRA_STREAM_NAMES) + if (arr != null) return arr.toList() + return emptyList() + } + + private fun applySettingsFromIntent(intent: Intent, startPlayback: Boolean) { + val newIsStream = intent.getBooleanExtra(EXTRA_IS_STREAM, false) + val newStreamUrls = readStreamUrls(intent) + val newStreamNames = readStreamNames(intent) + val newUris = intent.getStringArrayExtra(EXTRA_PLAYLIST_URIS).orEmpty() + val newNames = intent.getStringArrayExtra(EXTRA_PLAYLIST_NAMES).orEmpty() + val newShuffle = intent.getBooleanExtra(EXTRA_SHUFFLE, false) + currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() + currentSource = intent.getStringExtra(EXTRA_SOURCE).orEmpty() + + isStream = newIsStream + streamUrl = newStreamUrls.firstOrNull().orEmpty() + loadedStreamEntries = if (newIsStream) { + newStreamUrls.mapIndexed { i, url -> + StreamEntry(url = url, name = newStreamNames.getOrNull(i).orEmpty()) + } + } else emptyList() + loadedPlaylist = if (newIsStream) emptyList() + else newUris.mapIndexed { i, uri -> + PlaylistItem(uri, newNames.getOrNull(i).orEmpty()) + } + + val items = if (newIsStream) { + buildStreamItems(loadedStreamEntries) + } else { + buildLocalItems(loadedPlaylist) + } + if (items.isEmpty()) { + Log.w(TAG, "Keine abspielbaren Quellen gefunden") + publishCurrentState() + return + } + + releasePlayerInternal() + val exo = ExoPlayer.Builder(applicationContext).build().also { p -> + val attrs = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + p.setAudioAttributes(attrs, /* handleAudioFocus = */ false) + p.setHandleAudioBecomingNoisy(true) + if (newIsStream) { + p.setWakeMode(C.WAKE_MODE_NETWORK) + } else { + p.setWakeMode(C.WAKE_MODE_LOCAL) + } + p.setMediaItems(items) + p.repeatMode = Player.REPEAT_MODE_ALL + p.shuffleModeEnabled = newShuffle + p.volume = normalVolume + p.addListener(object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error) + publishCurrentState() + } + override fun onIsPlayingChanged(playing: Boolean) { + publishCurrentState() + refreshNotification() + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + publishCurrentState() + } + override fun onPlaybackStateChanged(playbackState: Int) { + publishCurrentState() + } + }) + p.prepare() + if (startPlayback) p.play() + } + player = exo + startProgressPolling() + refreshNotification() + publishCurrentState() + Log.i(TAG, "Player initialisiert (stream=$newIsStream, items=${items.size})") + } + + private fun handleStop() { + Log.i(TAG, "ACTION_STOP – Service wird beendet") + progressJob?.cancel() + runCatching { + player?.stop() + player?.release() + } + player = null + loadedPlaylist = emptyList() + loadedStreamEntries = emptyList() + publishStateExternal(MusicPlaybackState()) + stopForegroundCompat() + stopSelf() + } + + private fun handlePause() { + player?.pause() + publishCurrentState() + refreshNotification() + } + + private fun handleResume() { + val p = player ?: run { + Log.d(TAG, "Resume ignoriert – Player nicht aufgebaut") + return + } + p.volume = normalVolume + p.play() + publishCurrentState() + refreshNotification() + } + + private fun handleNext() { + val p = player ?: return + if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0) + publishCurrentState() + } + + private fun handlePrevious() { + val p = player ?: return + if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0) + publishCurrentState() + } + + private fun handleSetVolume(volume: Float) { + val v = volume.coerceIn(0f, 1f) + player?.volume = v + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + private fun releasePlayerInternal() { + progressJob?.cancel() + progressJob = null + runCatching { + player?.stop() + player?.release() + } + player = null + } + + private fun buildStreamItems(entries: List): List { + return entries.mapNotNull { entry -> + val url = entry.url + if (url.isBlank()) return@mapNotNull null + val lower = url.lowercase() + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + Log.w(TAG, "Stream-URL ungültig: $url") + return@mapNotNull null + } + MediaItem.Builder().setUri(Uri.parse(url)).build() + } + } + + private fun buildLocalItems(playlist: List): List { + return playlist.mapNotNull { item -> + runCatching { + val uri = Uri.parse(item.uriString) + runCatching { + applicationContext.contentResolver.takePersistableUriPermission( + uri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + MediaItem.Builder().setUri(uri).build() + }.onFailure { e -> + Log.e(TAG, "Ungültige URI: ${item.uriString}", e) + }.getOrNull() + } + } + + private fun startProgressPolling() { + progressJob?.cancel() + progressJob = mainScope.launch { + while (isActive) { + delay(500L) + publishCurrentState() + } + } + } + + private fun publishCurrentState() { + val p = player + if (p == null) { + publishStateExternal(MusicPlaybackState()) + return + } + val idx = p.currentMediaItemIndex + val total = p.mediaItemCount + val playlist = loadedPlaylist + val streams = loadedStreamEntries + + val title: String = when { + isStream -> { + if (streams.isNotEmpty() && idx in streams.indices) { + val entry = streams[idx] + entry.name.ifBlank { + runCatching { java.net.URL(entry.url).host.ifBlank { entry.url } } + .getOrElse { entry.url } + } + } else streamUrl + } + playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName + else -> "" + } + + val nextTitle: String = when { + isStream && streams.size > 1 -> { + val nextIdx = (idx + 1) % streams.size + val entry = streams.getOrNull(nextIdx) + entry?.let { e -> + e.name.ifBlank { + runCatching { java.net.URL(e.url).host.ifBlank { e.url } } + .getOrElse { e.url } + } + }.orEmpty() + } + isStream -> "" + playlist.size > 1 -> { + val nextIdx = (idx + 1) % playlist.size + if (nextIdx in playlist.indices) playlist[nextIdx].displayName else "" + } + else -> "" + } + + val sourceLabel: String = when { + isStream -> { + when { + streams.size > 1 -> "${streams.size} Streams" + else -> { + val url = streams.firstOrNull()?.url ?: streamUrl + runCatching { java.net.URL(url).host.ifBlank { url } }.getOrElse { url } + } + } + } + else -> "Playlist" + } + + val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L + val durationMs = if (!isStream) { + val d = p.duration + if (d > 0) d else 0L + } else 0L + + publishStateExternal( + MusicPlaybackState( + isPlaying = p.isPlaying, + hasContent = true, + currentTitle = title, + sourceLabel = sourceLabel, + isStream = isStream, + positionMs = positionMs, + durationMs = durationMs, + playlistIndex = if (isStream && streams.size > 1) idx + else if (!isStream) idx else -1, + playlistTotal = if (isStream && streams.size > 1) total + else if (!isStream) total else 0, + nextTitle = nextTitle, + supportsSkip = total > 1 + ) + ) + } + + // ─── Notification & Foreground ───────────────────────────────────────────── + + private fun refreshNotification() { + runCatching { + val nm = getSystemService(NotificationManager::class.java) + nm?.notify(NOTIFICATION_ID, buildNotification(currentTitle, currentSource)) + }.onFailure { e -> + Log.w(TAG, "Notification-Update fehlgeschlagen", e) + } + } + + private fun promoteToForeground(notification: Notification) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + foregroundStarted = true + } catch (e: Throwable) { + Log.w(TAG, "startForeground fehlgeschlagen", e) + } + } + + private fun stopForegroundCompat() { + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(Service.STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } + foregroundStarted = false + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = getSystemService(NotificationManager::class.java) ?: return + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.atmo_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.atmo_notification_channel_description) + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + private fun buildNotification(title: String, source: String): Notification { + val openAppIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val contentIntent = PendingIntent.getActivity( + this, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val stopIntent = Intent(this, AtmoPlaybackService::class.java).apply { + action = ACTION_STOP + } + val stopPending = PendingIntent.getService( + this, + 1, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val safeTitle = title.ifBlank { getString(R.string.atmo_notification_default_title) } + val safeSource = source.ifBlank { getString(R.string.atmo_notification_default_source) } + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(safeTitle) + .setContentText(safeSource) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setShowWhen(false) + .setContentIntent(contentIntent) + .addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_launcher, + getString(R.string.music_test_stop), + stopPending + ).build() + ) + .build() + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/AtmoResumeManager.kt b/app/src/main/kotlin/de/waypointaudio/service/AtmoResumeManager.kt new file mode 100644 index 0000000..ee71a71 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/AtmoResumeManager.kt @@ -0,0 +1,135 @@ +package de.waypointaudio.service + +import android.content.Context +import android.util.Log + +/** + * Zentrale Stelle für das Pausieren und Wiederaufnehmen der Atmo (Begleitmusik) + * bei beliebigen Unterbrechungen. + * + * Drei Unterbrechungsfälle in der App: + * 1. Wegpunkt-Audio (GPS-getriggert oder manuell) + * 2. Manueller Player (Einzeldatei / Playlist) + * 3. Push-To-Talk (Live-Sprechen) + * + * Kernregel: Bei jeder Unterbrechung **pause()** statt **stop()** aufrufen, + * damit der Player die aktuelle Position behält. Nach der Unterbrechung wird + * nur dann fortgesetzt, wenn die Atmo unmittelbar vor der Unterbrechung lief. + * + * Die Wegpunkt-Pfade ([BackgroundMusicPlayer.beforeWaypointAudio] / + * [BackgroundMusicPlayer.afterWaypointAudio]) bringen bereits eigene Fade/Duck- + * Logik mit – dieser Manager ist explizit für **alle** Unterbrechungswege ein + * einheitlicher Einstiegspunkt, der die korrekte pause/resume-Semantik + * sicherstellt und gegen doppelte Aufrufe robust ist. + */ +class AtmoResumeManager private constructor(private val appContext: Context) { + + companion object { + private const val TAG = "AtmoResumeManager" + + @Volatile + private var instance: AtmoResumeManager? = null + + fun getInstance(context: Context): AtmoResumeManager { + return instance ?: synchronized(this) { + instance ?: AtmoResumeManager(context.applicationContext).also { instance = it } + } + } + } + + /** + * Ein einzelner aktiver Unterbrechungs-Kontext. + * Hält den Zustand, ob die Atmo zum Zeitpunkt des Beginns spielte, + * damit beim Ende dieselbe Information für den Resume genutzt werden kann. + */ + enum class Source { WAYPOINT, MANUAL_PLAYER, PTT } + + /** + * Aktuell aktive Unterbrechungen (Set, da gleichzeitig auftretbar: + * z. B. Wegpunkt-Audio + PTT). Atmo wird erst fortgesetzt, wenn alle + * Unterbrechungen beendet sind. + */ + private val activeInterruptions = mutableSetOf() + + /** Snapshot, ob die Atmo lief, als die *erste* Unterbrechung begann. */ + private var wasAtmoPlayingAtFirstInterruption: Boolean = false + + /** + * Beginnt eine Unterbrechung. Pausiert die Atmo nur dann, wenn sie aktiv lief + * (Position wird intern in der MediaPlayer-Instanz erhalten). + */ + @Synchronized + fun notifyInterruptStart(source: Source) { + val firstInterruption = activeInterruptions.isEmpty() + activeInterruptions.add(source) + if (!firstInterruption) { + Log.d(TAG, "Zusätzliche Unterbrechung: $source (aktiv: $activeInterruptions)") + return + } + runCatching { + val mgr = BackgroundMusicManager.getInstance(appContext) + val playingNow = mgr.player.isPlaying + wasAtmoPlayingAtFirstInterruption = playingNow + if (playingNow) { + mgr.pauseMusic() + Log.d(TAG, "Atmo pausiert wegen $source") + } else { + Log.d(TAG, "Atmo lief nicht – keine Pause nötig ($source)") + } + }.onFailure { e -> + Log.w(TAG, "Konnte Atmo nicht pausieren ($source)", e) + wasAtmoPlayingAtFirstInterruption = false + } + } + + /** + * Beendet eine Unterbrechung. Atmo wird nur dann fortgesetzt, wenn + * - sie zum Zeitpunkt der *ersten* Unterbrechung lief, und + * - keine weitere Unterbrechung mehr aktiv ist. + * + * Wurde die Atmo zwischenzeitlich vom Nutzer komplett gestoppt + * ([BackgroundMusicPlayer.stop]), so ist `player.hasContent == false` + * und wir versuchen keinen Resume. + */ + @Synchronized + fun notifyInterruptEnd(source: Source) { + activeInterruptions.remove(source) + if (activeInterruptions.isNotEmpty()) { + Log.d(TAG, "Unterbrechung $source beendet, weitere aktiv: $activeInterruptions") + return + } + val shouldResume = wasAtmoPlayingAtFirstInterruption + wasAtmoPlayingAtFirstInterruption = false + if (!shouldResume) { + Log.d(TAG, "Atmo nicht fortgesetzt: vor Unterbrechung nicht aktiv") + return + } + runCatching { + val mgr = BackgroundMusicManager.getInstance(appContext) + val settings = mgr.currentSettings + if (!settings.enabled) { + Log.d(TAG, "Atmo nicht fortgesetzt: in Tour-Einstellungen deaktiviert") + return@runCatching + } + if (!mgr.player.hasContent) { + Log.d(TAG, "Atmo nicht fortgesetzt: während Unterbrechung manuell gestoppt") + return@runCatching + } + if (mgr.player.isPlaying) { + Log.d(TAG, "Atmo läuft bereits – kein erneuter Start") + return@runCatching + } + mgr.player.play() + Log.d(TAG, "Atmo fortgesetzt nach $source") + }.onFailure { e -> + Log.w(TAG, "Konnte Atmo nicht fortsetzen ($source)", e) + } + } + + /** Notfall-Reset (z. B. Service-Restart). */ + @Synchronized + fun reset() { + activeInterruptions.clear() + wasAtmoPlayingAtFirstInterruption = false + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt index 0967102..4a08e35 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt @@ -104,6 +104,44 @@ class AudioPlayer { mediaPlayer = null } + /** + * Pausiert die aktuelle Wiedergabe ohne den MediaPlayer freizugeben. + * Anschließend kann [resume] aufgerufen werden, um an derselben Position + * weiterzuspielen. Wird verwendet, wenn PTT-Sprechen einsetzt und das + * Wegpunkt-Audio während dieser Zeit nicht hörbar sein soll. + */ + fun pause() { + runCatching { + mediaPlayer?.let { + if (it.isPlaying) it.pause() + } + }.onFailure { e -> + Log.w(TAG, "Pause fehlgeschlagen", e) + } + } + + /** + * Setzt eine zuvor mit [pause] angehaltene Wiedergabe fort. Wenn der + * MediaPlayer bereits freigegeben wurde oder nicht im Pause-Zustand ist, + * passiert nichts. + */ + fun resume() { + runCatching { + val mp = mediaPlayer ?: return + if (!mp.isPlaying) mp.start() + }.onFailure { e -> + Log.w(TAG, "Resume fehlgeschlagen", e) + } + } + + /** True, wenn aktuell eine Wiedergabe aktiv ist (nicht pausiert/gestoppt). */ + val isPlaying: Boolean + get() = runCatching { mediaPlayer?.isPlaying == true }.getOrDefault(false) + + /** True, wenn ein MediaPlayer existiert (spielt oder pausiert). */ + val hasContent: Boolean + get() = mediaPlayer != null + companion object { private const val TAG = "AudioPlayer" } diff --git a/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt index 5b946b8..8fa2b71 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt @@ -1,16 +1,8 @@ package de.waypointaudio.service import android.content.Context -import android.net.Uri import android.util.Log -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource import de.waypointaudio.data.MusicSourceType -import de.waypointaudio.data.PlaylistItem import de.waypointaudio.data.TourAudioSettings import de.waypointaudio.data.WaypointMusicBehavior import kotlinx.coroutines.CoroutineScope @@ -19,12 +11,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow /** * Zustand des Begleitmusik-Players, der in der UI angezeigt werden kann. @@ -55,18 +44,27 @@ data class MusicPlaybackState( ) /** - * Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer. + * Begleitmusik-Player – ab v2.0.7 ein **dünner Facade-Wrapper** um + * [AtmoPlaybackService]. * - * Unterstützt: - * - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert) - * - Direkte HTTP/HTTPS Audio-Stream-URLs - * - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen - * - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint) + * Architektur: + * - Die eigentliche ExoPlayer-Instanz lebt im Foreground-Service + * [AtmoPlaybackService]. Der Service wird gestartet, sobald + * [play] / [buildPlayer] aufgerufen werden, und bleibt am Leben, bis + * [stop] aufgerufen wird. + * - Der Service exponiert einen process-singleton StateFlow, den dieser + * Player über [playbackState] direkt weiterleitet. UI und ViewModel + * abonnieren denselben Flow. + * - Pause/Resume/Next/Previous/SetVolume gehen ausschließlich über + * Service-Intents – die Activity-Lifecycle hat keinen Einfluss mehr + * auf die Wiedergabe. * - * Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen. + * Bestehende öffentliche API (siehe v2.0.6) bleibt erhalten, damit Manager + * und ViewModel keine Anpassungen brauchen. [isPlaying] und [hasContent] + * werden jetzt aus dem letzten Snapshot des Service-StateFlows abgeleitet. * - * Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen. - * Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden. + * Fade-/Duck-Animationen werden über kurze Koroutinen umgesetzt, die + * wiederholt [AtmoPlaybackService.setVolume] aufrufen. */ class BackgroundMusicPlayer(private val context: Context) { @@ -76,49 +74,37 @@ class BackgroundMusicPlayer(private val context: Context) { } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var player: ExoPlayer? = null private var fadeJob: Job? = null - private var progressJob: Job? = null - // Aktuell geladene Einstellungen private var currentSettings: TourAudioSettings? = null - - // Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade) private var normalVolume: Float = 1.0f + private var lastSetVolume: Float = 1.0f - // True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird private var waypointSuppressed: Boolean = false - - // True wenn Player war aktiv bevor Waypoint ausgelöst wurde private var wasPlayingBeforeWaypoint: Boolean = false - // Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup) - private var loadedPlaylist: List = emptyList() + /** + * State-Flow direkt vom Service. Wenn der Service noch nie gestartet wurde, + * enthält der Flow den leeren MusicPlaybackState (alle Felder default). + */ + val playbackState: StateFlow = AtmoPlaybackService.state // ----------------------------------------------------------------------- - // Öffentlicher State-Flow + // Öffentliche Status-Getter (abgeleitet aus dem Service-Flow) // ----------------------------------------------------------------------- - private val _playbackState = MutableStateFlow(MusicPlaybackState()) - /** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */ - val playbackState: StateFlow = _playbackState.asStateFlow() + /** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */ + val isPlaying: Boolean + get() = playbackState.value.isPlaying + + /** Ob ein Track geladen ist (auch wenn pausiert). */ + val hasContent: Boolean + get() = playbackState.value.hasContent // ----------------------------------------------------------------------- // Öffentliche API // ----------------------------------------------------------------------- - /** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */ - val isPlaying: Boolean - get() = player?.isPlaying == true - - /** Ob ein Track geladen ist (auch wenn pausiert). */ - val hasContent: Boolean - get() = player != null - - /** - * Lädt neue Einstellungen und bereitet den Player vor. - * Stoppt zuvor laufende Wiedergabe wenn Quelle sich geändert hat. - */ fun loadSettings(settings: TourAudioSettings) { currentSettings = settings normalVolume = 1.0f @@ -126,73 +112,77 @@ class BackgroundMusicPlayer(private val context: Context) { /** * Startet Wiedergabe mit den aktuell geladenen Einstellungen. - * Baut ExoPlayer auf wenn nötig. + * Wenn der Service schon läuft mit gleicher Quelle: einfach Resume. + * Sonst: neue Quelle an Service übergeben (Service baut Player neu auf). */ fun play() { val settings = currentSettings ?: return if (!settings.enabled) return + val srcOk = when (settings.sourceType) { + MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty() + MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty() + } + if (!srcOk) { + Log.w(TAG, "play(): keine Quelle konfiguriert") + return + } - if (player == null) { - buildPlayer(settings) + if (hasContent) { + // Service hat bereits einen Player – Resume genügt. + AtmoPlaybackService.resume(context) } else { - player?.play() + buildPlayer(settings) } } /** Pausiert Wiedergabe ohne Ressourcen freizugeben. */ fun pause() { cancelFade() - player?.pause() - updateState() + AtmoPlaybackService.pause(context) } - /** Stoppt Wiedergabe und gibt ExoPlayer frei. */ - fun stop() { - cancelFade() - releasePlayer() - updateState() - } - - /** Nächster Titel in der Playlist (nur für lokale Playlists sinnvoll). */ - fun next() { - val p = player ?: return - if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0) - updateState() - } - - /** Vorheriger Titel in der Playlist. */ - fun previous() { - val p = player ?: return - if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0) - updateState() - } - - /** Setzt die Lautstärke sofort (0.0 – 1.0). */ - fun setVolume(volume: Float) { - player?.volume = volume.coerceIn(0f, 1f) - } - - /** Gibt alle Ressourcen frei. Nach diesem Aufruf ist die Instanz nicht mehr nutzbar. */ - fun release() { - scope.cancel() - releasePlayer() - } - - // ----------------------------------------------------------------------- - // Wegpunkt-Interaktion - // ----------------------------------------------------------------------- - /** - * Vor dem Abspielen eines Wegpunkt-Audios aufrufen. - * Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter). + * Stoppt Wiedergabe komplett und beendet den Service. + * Service stoppt sich danach selbst (FGS-Notification verschwindet). */ + fun stop() { + cancelFade() + AtmoPlaybackService.stop(context) + } + + fun next() { + AtmoPlaybackService.next(context) + } + + fun previous() { + AtmoPlaybackService.previous(context) + } + + fun setVolume(volume: Float) { + val v = volume.coerceIn(0f, 1f) + lastSetVolume = v + AtmoPlaybackService.setVolume(context, v) + } + + /** + * Gibt alle Ressourcen frei. Wird beim App-Shutdown aufgerufen. + * Der Service wird über [stop] beendet. + */ + fun release() { + scope.cancel() + AtmoPlaybackService.stop(context) + } + + // ----------------------------------------------------------------------- + // Wegpunkt-Interaktion (verändert sich nicht – delegiert an Service via API) + // ----------------------------------------------------------------------- + fun beforeWaypointAudio(settings: TourAudioSettings) { if (!settings.enabled) return wasPlayingBeforeWaypoint = isPlaying waypointSuppressed = true - // Nur wenn player vorhanden (Musik läuft/pausiert) - if (player == null) return + if (!hasContent) return when (settings.behavior) { WaypointMusicBehavior.PAUSE_RESUME -> { @@ -201,7 +191,7 @@ class BackgroundMusicPlayer(private val context: Context) { WaypointMusicBehavior.FADE_OUT_IN -> { if (wasPlayingBeforeWaypoint) { fadeOut(settings.fadeDurationMs) { - player?.pause() + AtmoPlaybackService.pause(context) } } } @@ -211,16 +201,11 @@ class BackgroundMusicPlayer(private val context: Context) { } } WaypointMusicBehavior.CONTINUE_UNDERLAY -> { - // Nichts tun – Musik läuft auf normaler Lautstärke weiter + // Nichts tun. } } } - /** - * Nach dem Abspielen eines Wegpunkt-Audios aufrufen. - * Stellt vorherigen Zustand wieder her. - * Wenn autoStartAfterWaypoint aktiv und Musik war nicht spielend, wird sie gestartet. - */ fun afterWaypointAudio(settings: TourAudioSettings) { if (!settings.enabled) { waypointSuppressed = false @@ -228,7 +213,6 @@ class BackgroundMusicPlayer(private val context: Context) { } val wasSuppressed = waypointSuppressed waypointSuppressed = false - if (!wasSuppressed) return val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint @@ -236,15 +220,15 @@ class BackgroundMusicPlayer(private val context: Context) { when (settings.behavior) { WaypointMusicBehavior.PAUSE_RESUME -> { if (wasPlayingBeforeWaypoint) { - player?.play() + AtmoPlaybackService.resume(context) } else if (shouldAutoStart) { startOrPlay(settings) } } WaypointMusicBehavior.FADE_OUT_IN -> { if (wasPlayingBeforeWaypoint) { - player?.volume = 0f - player?.play() + AtmoPlaybackService.setVolume(context, 0f) + AtmoPlaybackService.resume(context) fadeIn(settings.fadeDurationMs) } else if (shouldAutoStart) { startOrPlay(settings) @@ -259,9 +243,7 @@ class BackgroundMusicPlayer(private val context: Context) { } } WaypointMusicBehavior.CONTINUE_UNDERLAY -> { - if (shouldAutoStart) { - startOrPlay(settings) - } + if (shouldAutoStart) startOrPlay(settings) } } } @@ -270,186 +252,25 @@ class BackgroundMusicPlayer(private val context: Context) { // Interner Aufbau // ----------------------------------------------------------------------- - /** - * Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play(). - */ private fun startOrPlay(settings: TourAudioSettings) { - if (player == null) { + if (!hasContent) { buildPlayer(settings) } else { - player?.play() + AtmoPlaybackService.resume(context) } } + /** + * Sendet die neue Quelle an den Service. Der Service wird ggf. gestartet + * (mit `startForegroundService`) und baut den ExoPlayer intern auf. + */ private fun buildPlayer(settings: TourAudioSettings) { - releasePlayer() - - val isStream = settings.sourceType == MusicSourceType.STREAM_URL - val items = when (settings.sourceType) { - MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist) - MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl) - } - - if (items.isEmpty()) { - Log.w(TAG, "Keine abspielbaren Quellen gefunden") - return - } - - // Playlist-Snapshot für Titel-Lookup speichern - loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist - - val exo = ExoPlayer.Builder(context).build().also { p -> - p.setMediaItems(items) - p.repeatMode = Player.REPEAT_MODE_ALL - p.shuffleModeEnabled = settings.shuffle - p.volume = normalVolume - p.addListener(object : Player.Listener { - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error) - updateState() - } - override fun onIsPlayingChanged(playing: Boolean) { - updateState() - } - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - updateState() - } - override fun onPlaybackStateChanged(playbackState: Int) { - updateState() - } - }) - p.prepare() - p.play() - } - player = exo - Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})") - startProgressPolling() - updateState() - } - - /** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */ - private fun startProgressPolling() { - progressJob?.cancel() - progressJob = scope.launch { - while (isActive) { - delay(500L) - updateState() - } - } - } - - /** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */ - private fun updateState() { - val p = player - val settings = currentSettings - if (p == null || settings == null) { - _playbackState.value = MusicPlaybackState() - return - } - val isStream = settings.sourceType == MusicSourceType.STREAM_URL - val idx = p.currentMediaItemIndex - val total = p.mediaItemCount - val playlist = loadedPlaylist - - // Titel des aktuellen Tracks - val currentTitle: String = when { - isStream -> settings.streamUrl ?: "" - playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName - else -> "" - } - - // Titel des nächsten Tracks - val nextTitle: String = when { - isStream -> "" - playlist.size > 1 -> { - val nextIdx = (idx + 1) % playlist.size - if (nextIdx in playlist.indices) playlist[nextIdx].displayName else "" - } - else -> "" - } - - // Quell-Label - val sourceLabel: String = when { - isStream -> { - val url = settings.streamUrl ?: "" - runCatching { - java.net.URL(url).host.ifBlank { url } - }.getOrElse { url } - } - else -> "Playlist" - } - - // Position und Dauer nur für lokale Dateien mit bekannter Länge - val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L - val durationMs = if (!isStream) { - val d = p.duration - if (d > 0) d else 0L - } else 0L - - _playbackState.value = MusicPlaybackState( - isPlaying = p.isPlaying, - hasContent = true, - currentTitle = currentTitle, - sourceLabel = sourceLabel, - isStream = isStream, - positionMs = positionMs, - durationMs = durationMs, - playlistIndex = if (!isStream) idx else -1, - playlistTotal = if (!isStream) total else 0, - nextTitle = nextTitle, - supportsSkip = !isStream && total > 1 - ) - } - - private fun buildLocalItems(playlist: List): List { - return playlist.mapNotNull { item -> - runCatching { - val uri = Uri.parse(item.uriString) - // Persistable URI-Berechtigung sichern (beste-Effort) - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - MediaItem.Builder() - .setUri(uri) - .build() - }.onFailure { e -> - Log.e(TAG, "Ungültige URI: ${item.uriString}", e) - }.getOrNull() - } - } - - private fun buildStreamItems(streamUrl: String?): List { - if (streamUrl.isNullOrBlank()) return emptyList() - val lower = streamUrl.lowercase() - if (!lower.startsWith("http://") && !lower.startsWith("https://")) { - Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl") - return emptyList() - } - return listOf( - MediaItem.Builder() - .setUri(Uri.parse(streamUrl)) - .build() - ) - } - - private fun releasePlayer() { - cancelFade() - progressJob?.cancel() - progressJob = null - runCatching { - player?.stop() - player?.release() - } - player = null - loadedPlaylist = emptyList() - _playbackState.value = MusicPlaybackState() + AtmoPlaybackService.start(context, settings) + Log.i(TAG, "BackgroundMusicPlayer.buildPlayer → Service gestartet (${settings.sourceType})") } // ----------------------------------------------------------------------- - // Fade-Helfer (Koroutinen-basiert) + // Fade-Helfer // ----------------------------------------------------------------------- private fun cancelFade() { @@ -459,7 +280,7 @@ class BackgroundMusicPlayer(private val context: Context) { private fun fadeOut(durationMs: Long, onDone: () -> Unit) { cancelFade() - val startVolume = player?.volume ?: 1f + val startVolume = lastSetVolume.coerceIn(0f, 1f) fadeJob = scope.launch { val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1) val stepSize = startVolume / steps @@ -467,13 +288,13 @@ class BackgroundMusicPlayer(private val context: Context) { repeat(steps.toInt()) { if (!isActive) return@launch current = (current - stepSize).coerceAtLeast(0f) - withContext(Dispatchers.Main) { player?.volume = current } + AtmoPlaybackService.setVolume(context, current) + lastSetVolume = current delay(FADE_STEP_INTERVAL_MS) } - withContext(Dispatchers.Main) { - player?.volume = 0f - onDone() - } + AtmoPlaybackService.setVolume(context, 0f) + lastSetVolume = 0f + onDone() } } @@ -487,16 +308,18 @@ class BackgroundMusicPlayer(private val context: Context) { repeat(steps.toInt()) { if (!isActive) return@launch current = (current + stepSize).coerceAtMost(targetVolume) - withContext(Dispatchers.Main) { player?.volume = current } + AtmoPlaybackService.setVolume(context, current) + lastSetVolume = current delay(FADE_STEP_INTERVAL_MS) } - withContext(Dispatchers.Main) { player?.volume = targetVolume } + AtmoPlaybackService.setVolume(context, targetVolume) + lastSetVolume = targetVolume } } private fun fadeToVolume(targetVolume: Float, durationMs: Long) { cancelFade() - val startVolume = player?.volume ?: normalVolume + val startVolume = lastSetVolume.coerceIn(0f, 1f) fadeJob = scope.launch { val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1) val delta = (targetVolume - startVolume) / steps @@ -504,10 +327,12 @@ class BackgroundMusicPlayer(private val context: Context) { repeat(steps.toInt()) { if (!isActive) return@launch current = (current + delta).coerceIn(0f, 1f) - withContext(Dispatchers.Main) { player?.volume = current } + AtmoPlaybackService.setVolume(context, current) + lastSetVolume = current delay(FADE_STEP_INTERVAL_MS) } - withContext(Dispatchers.Main) { player?.volume = targetVolume } + AtmoPlaybackService.setVolume(context, targetVolume) + lastSetVolume = targetVolume } } } diff --git a/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt b/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt index 3c8fd5f..59f6928 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt @@ -1,10 +1,18 @@ package de.waypointaudio.service +import android.Manifest +import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices import de.waypointaudio.data.AudioRoutingSettings import de.waypointaudio.data.AudioRoutingStore +import de.waypointaudio.data.PttRecording +import de.waypointaudio.data.PttRecordingFormat +import de.waypointaudio.data.PttRecordingStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -14,23 +22,35 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull /** * App-weiter Singleton-Manager für Live / PTT. * - * Steuert den [LivePttService] (startet/stoppt den Vordergrundservice) und - * verwaltet den Status (pttActive, Gerätewahl, Fehler). + * Steuert den [LivePttService] (Live-Ausgabe) und/oder den [PttRecorder] (Aufnahme), + * je nach Nutzer-Einstellungen ([AudioRoutingSettings.liveOutputEnabled], + * [AudioRoutingSettings.recordingEnabled]). * * Audio-Priorität: * - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]). * - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe. * - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]). * + * Aufnahme: + * - MediaRecorder läuft parallel zum Live-Loop (AudioRecord). Auf einigen Geräten + * kann eines von beiden versagen; in diesem Fall wird der Vorgang fortgesetzt + * und die UI über [statusMessage] informiert. + * - Aufnahmen werden in /recordings/ abgelegt und in [PttRecordingStore] + * indiziert. Wegpunkt-Zuweisung erfolgt über das Standard-soundUri-Feld. + * * Bekannte Einschränkungen: * - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt. * - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing. - * Dieser MVP setzt setPreferredDevice() und loggt Fehler. * - Echo/Feedback: Bei Nutzung des eingebauten Lautsprechers ohne Headset zu erwarten. + * - Live + Aufnahme gleichzeitig: Auf einigen Geräten konkurrieren AudioRecord + * (Live) und MediaRecorder (Aufnahme) um das Mikrofon. Bei Fehler wird die + * Aufnahme bevorzugt und die Live-Ausgabe übersprungen (oder umgekehrt). */ class LivePttManager private constructor(private val appContext: Context) { @@ -49,6 +69,9 @@ class LivePttManager private constructor(private val appContext: Context) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val store = AudioRoutingStore(appContext) + private val recordingStore = PttRecordingStore(appContext) + private val recorder = PttRecorder(appContext) + private val fusedLocation = LocationServices.getFusedLocationProviderClient(appContext) // ─── Öffentliche States ─────────────────────────────────────────────────── @@ -56,15 +79,19 @@ class LivePttManager private constructor(private val appContext: Context) { private val _pttActive = MutableStateFlow(false) val pttActive: StateFlow = _pttActive.asStateFlow() - /** Aktuell gespeicherte Routing-Einstellungen. */ + /** true wenn aktuell eine Aufnahme läuft. */ + private val _recordingActive = MutableStateFlow(false) + val recordingActive: StateFlow = _recordingActive.asStateFlow() + + /** Aktuell gespeicherte Routing-/PTT-Einstellungen. */ private val _routingSettings = MutableStateFlow(AudioRoutingSettings()) val routingSettings: StateFlow = _routingSettings.asStateFlow() - /** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */ + /** Verfügbare Eingabegeräte. */ private val _inputDevices = MutableStateFlow>(emptyList()) val inputDevices: StateFlow> = _inputDevices.asStateFlow() - /** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */ + /** Verfügbare Ausgabegeräte. */ private val _outputDevices = MutableStateFlow>(emptyList()) val outputDevices: StateFlow> = _outputDevices.asStateFlow() @@ -75,10 +102,27 @@ class LivePttManager private constructor(private val appContext: Context) { private val _statusMessage = MutableStateFlow(null) val statusMessage: StateFlow = _statusMessage.asStateFlow() + /** + * Reaktiver Flow der gespeicherten Aufnahmen. Wird von der UI für Bibliothek + * und Post-Aufnahme-Dialog beobachtet. + */ + val recordings: Flow> = recordingStore.recordings + + /** + * Zuletzt fertig gestellte Aufnahme (für Post-Aufnahme-Dialog). + * Wird von der UI mit [clearLastRecording] zurückgesetzt. + */ + private val _lastRecording = MutableStateFlow(null) + val lastRecording: StateFlow = _lastRecording.asStateFlow() + + /** + * Zentraler Atmo-Resume-Manager. + */ + private val atmoResume = AtmoResumeManager.getInstance(appContext) + // ─── Initialisierung ────────────────────────────────────────────────────── init { - // Gespeicherte Einstellungen laden scope.launch { store.settings.collect { settings -> _routingSettings.value = settings @@ -89,10 +133,7 @@ class LivePttManager private constructor(private val appContext: Context) { // ─── PTT starten / stoppen ──────────────────────────────────────────────── /** - * Startet PTT: - * 1. Atmo pausieren - * 2. Foreground Service starten - * 3. Status setzen + * Startet PTT (Live und/oder Aufnahme) abhängig von den Einstellungen. */ fun startPtt() { if (_pttActive.value) { @@ -100,41 +141,69 @@ class LivePttManager private constructor(private val appContext: Context) { return } - // Begleitmusik pausieren + val settings = _routingSettings.value + // Sicherheit: Wenn der Nutzer beides deaktiviert hat, Live-Modus erzwingen. + val live = settings.liveOutputEnabled || !settings.recordingEnabled + val record = settings.recordingEnabled + + // Begleitmusik & Wegpunkt-Audio pausieren pauseAtmo() - val settings = _routingSettings.value - val intent = Intent(appContext, LivePttService::class.java).apply { - action = LivePttService.ACTION_START_PTT - settings.selectedInputDeviceId?.let { - putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it) - } - settings.selectedOutputDeviceId?.let { - putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it) + var liveStarted = false + var recordStarted = false + + // Aufnahme zuerst starten (höhere Priorität für saubere Datei). Wenn beides + // aktiv ist, beansprucht MediaRecorder den MIC-Kanal; der Live-AudioRecord + // läuft auf VOICE_COMMUNICATION und teilt sich auf den meisten Geräten den + // Eingang ohne Konflikt. + if (record) { + val file = recorder.start(settings.recordingFormat) + if (file != null) { + recordStarted = true + _recordingActive.value = true + Log.i(TAG, "PTT-Aufnahme gestartet: ${file.absolutePath}") + } else { + _statusMessage.value = "Aufnahme konnte nicht gestartet werden" + Log.w(TAG, "PTT-Aufnahme konnte nicht gestartet werden") } } - runCatching { - appContext.startForegroundService(intent) + if (live) { + val intent = Intent(appContext, LivePttService::class.java).apply { + action = LivePttService.ACTION_START_PTT + settings.selectedInputDeviceId?.let { + putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it) + } + settings.selectedOutputDeviceId?.let { + putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it) + } + } + runCatching { + appContext.startForegroundService(intent) + liveStarted = true + Log.i(TAG, "PTT-Live gestartet") + }.onFailure { e -> + Log.e(TAG, "PTT-Live konnte nicht gestartet werden", e) + _statusMessage.value = "PTT-Live-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}" + } + } + + if (liveStarted || recordStarted) { _pttActive.value = true - _statusMessage.value = null - Log.i(TAG, "PTT gestartet") - }.onFailure { e -> - Log.e(TAG, "PTT konnte nicht gestartet werden", e) - _statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}" + } else { + _statusMessage.value = "PTT konnte nicht gestartet werden" restoreAtmo() } } /** - * Stoppt PTT: - * 1. Foreground Service stoppen - * 2. Atmo wiederherstellen - * 3. Status setzen + * Stoppt PTT. Aufnahme wird finalisiert und in der Bibliothek gespeichert; + * die fertige Aufnahme wird in [lastRecording] gepostet. */ fun stopPtt() { if (!_pttActive.value) return + // Live-Service stoppen (falls aktiv) val intent = Intent(appContext, LivePttService::class.java).apply { action = LivePttService.ACTION_STOP_PTT } @@ -144,6 +213,26 @@ class LivePttManager private constructor(private val appContext: Context) { Log.w(TAG, "Fehler beim Stoppen des PTT-Service", e) } + // Aufnahme finalisieren – Standort optional ermitteln + if (_recordingActive.value) { + scope.launch { + val (lat, lon) = fetchLastLocationOrNull() + val meta = recorder.stop(latitude = lat, longitude = lon) + _recordingActive.value = false + if (meta != null) { + runCatching { + recordingStore.add(meta) + }.onFailure { e -> + Log.e(TAG, "Aufnahme-Metadaten konnten nicht gespeichert werden", e) + } + _lastRecording.value = meta + Log.i(TAG, "PTT-Aufnahme gespeichert: ${meta.filePath} (${meta.durationMs} ms)") + } else { + Log.w(TAG, "PTT-Aufnahme lieferte keine Metadaten zurück (Fehler oder zu kurz)") + } + } + } + _pttActive.value = false _statusMessage.value = null restoreAtmo() @@ -155,63 +244,105 @@ class LivePttManager private constructor(private val appContext: Context) { if (_pttActive.value) stopPtt() else startPtt() } + /** Verwirft die zuletzt fertig gestellte Aufnahme aus der UI-Anzeige (Dialog geschlossen). */ + fun clearLastRecording() { + _lastRecording.value = null + } + + // ─── Aufnahme-Bibliothek ────────────────────────────────────────────────── + + /** Löscht eine Aufnahme (Datei + Metadaten). */ + fun deleteRecording(recording: PttRecording) { + scope.launch { + runCatching { + val f = java.io.File(recording.filePath) + if (f.exists()) f.delete() + recordingStore.delete(recording.id) + Log.i(TAG, "Aufnahme gelöscht: ${recording.displayName}") + }.onFailure { e -> + Log.e(TAG, "Fehler beim Löschen der Aufnahme", e) + } + } + } + + /** Benennt eine Aufnahme um (nur Anzeigename). */ + fun renameRecording(recording: PttRecording, newName: String) { + scope.launch { + runCatching { recordingStore.rename(recording.id, newName.trim()) } + .onFailure { e -> Log.e(TAG, "Fehler beim Umbenennen", e) } + } + } + // ─── Geräteverwaltung ──────────────────────────────────────────────────── - /** Lädt die verfügbaren Audiogeräte neu. */ fun refreshDevices() { _inputDevices.value = AudioDeviceManager.getInputDevices(appContext) _outputDevices.value = AudioDeviceManager.getOutputDevices(appContext) Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe") } - /** Speichert neue Routing-Einstellungen persistent. */ fun saveRoutingSettings(settings: AudioRoutingSettings) { - _routingSettings.value = settings + // Validierung: mindestens eine Option (Live oder Aufnahme) muss aktiv sein. + val safe = if (!settings.liveOutputEnabled && !settings.recordingEnabled) { + settings.copy(liveOutputEnabled = true) + } else settings + _routingSettings.value = safe scope.launch { runCatching { - store.save(settings) - Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}") + store.save(safe) + Log.i(TAG, "PTT-Einstellungen gespeichert: live=${safe.liveOutputEnabled}, " + + "record=${safe.recordingEnabled}, format=${safe.recordingFormat}") }.onFailure { e -> - Log.e(TAG, "Fehler beim Speichern der Routing-Einstellungen", e) + Log.e(TAG, "Fehler beim Speichern der PTT-Einstellungen", e) } } } - /** Löscht die Statusmeldung. */ fun clearStatus() { _statusMessage.value = null } + // ─── Standort-Abfrage für Aufnahme-Metadaten ───────────────────────────── + + @SuppressLint("MissingPermission") + private suspend fun fetchLastLocationOrNull(): Pair { + val fine = ContextCompat.checkSelfPermission( + appContext, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val coarse = ContextCompat.checkSelfPermission( + appContext, Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (!fine && !coarse) return null to null + return withTimeoutOrNull(1500L) { + runCatching { + val loc = fusedLocation.lastLocation.await() + if (loc != null) loc.latitude to loc.longitude else null to null + }.getOrElse { null to null } + } ?: (null to null) + } + // ─── Atmo-Integration ──────────────────────────────────────────────────── private fun pauseAtmo() { + atmoResume.notifyInterruptStart(AtmoResumeManager.Source.PTT) runCatching { - val mgr = BackgroundMusicManager.getInstance(appContext) - if (mgr.player.isPlaying) { - mgr.pauseMusic() - Log.d(TAG, "Atmo pausiert für PTT") - } + WaypointAudioInterruptionCoordinator.beginInterruption() }.onFailure { e -> - Log.w(TAG, "Konnte Atmo nicht pausieren: ${e.message}") + Log.w(TAG, "Konnte Wegpunkt-Audio nicht pausieren (PTT-Start)", e) } } private fun restoreAtmo() { runCatching { - val mgr = BackgroundMusicManager.getInstance(appContext) - val settings = mgr.currentSettings - if (settings.enabled) { - mgr.afterWaypointAudio() - Log.d(TAG, "Atmo nach PTT wiederhergestellt") - } + WaypointAudioInterruptionCoordinator.endInterruption() }.onFailure { e -> - Log.w(TAG, "Konnte Atmo nicht wiederherstellen: ${e.message}") + Log.w(TAG, "Konnte Wegpunkt-Audio nicht fortsetzen (PTT-Ende)", e) } + atmoResume.notifyInterruptEnd(AtmoResumeManager.Source.PTT) } // ─── Lifecycle ──────────────────────────────────────────────────────────── - /** Ressourcen freigeben. Danach nicht mehr nutzbar. */ fun release() { if (_pttActive.value) stopPtt() scope.cancel() diff --git a/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt index 5acce80..2f96146 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt @@ -3,6 +3,7 @@ package de.waypointaudio.service import android.content.Context import android.media.MediaPlayer import android.net.Uri +import android.os.PowerManager import android.util.Log /** @@ -22,6 +23,8 @@ class ManualAudioPlayer { private var mediaPlayer: MediaPlayer? = null private var currentUri: String? = null + /** v2.5.10 — App-Context für FGS-Stop aus releasePlayer/stop heraus. */ + private var lastAppContext: Context? = null /** * Whether the player is currently in single-item mode. @@ -42,6 +45,24 @@ class ManualAudioPlayer { val hasTrack: Boolean get() = mediaPlayer != null + /** + * v2.5.14 — Aktuelle Wiedergabeposition in Millisekunden. Liefert 0, wenn + * kein Player geladen ist oder die Position (noch) nicht ermittelbar ist. + */ + fun getCurrentPositionMs(): Long = runCatching { + mediaPlayer?.currentPosition?.toLong() ?: 0L + }.getOrDefault(0L) + + /** + * v2.5.14 — Gesamtdauer der aktuell geladenen Datei in Millisekunden. + * Liefert -1, wenn unbekannt (Streams, noch nicht geprepared, oder bereits + * freigegeben). UI-Layer sollte einen `--:--`-Fallback rendern. + */ + fun getDurationMs(): Long = runCatching { + val d = mediaPlayer?.duration ?: -1 + if (d <= 0) -1L else d.toLong() + }.getOrDefault(-1L) + // ------------------------------------------------------------------------- // Playlist mode (existing global manual player bar) // ------------------------------------------------------------------------- @@ -169,6 +190,9 @@ class ManualAudioPlayer { mediaPlayer = MediaPlayer().apply { // Grant URI permission for content:// URIs originating from SAF setDataSource(context, uri) + // v2.5.10 — MediaPlayer-eigener WakeLock als zweites Sicherheitsnetz. + // Greift zusätzlich zum PARTIAL_WAKE_LOCK des ManualPlaybackService. + setWakeMode(context.applicationContext, PowerManager.PARTIAL_WAKE_LOCK) prepare() start() setOnCompletionListener { @@ -183,12 +207,43 @@ class ManualAudioPlayer { } } currentUri = soundUri + lastAppContext = context.applicationContext + // v2.5.10 — Foreground-Service als Lebenshalter starten, sobald + // tatsächlich Wiedergabe läuft. Updates der Notification-Texte + // erfolgen aus dem ViewModel über setMeta/start. + ManualPlaybackService.start( + context.applicationContext, + title = pendingTitle.takeIf { it.isNotBlank() } ?: "GPS2Audio", + tour = pendingTour + ) }.onFailure { e -> Log.e(TAG, "Fehler beim Abspielen von $soundUri", e) releasePlayer() } } + // v2.5.10 — Optionale Anzeige-Metadaten für die Foreground-Notification. + // Die ViewModel-Schicht setzt diese vor dem nächsten play()-Aufruf. + @Volatile private var pendingTitle: String = "" + @Volatile private var pendingTour: String = "" + + /** Setzt Titel/Tour, die beim nächsten Start als Notification-Text erscheinen. */ + fun setNextNotificationMeta(title: String?, tour: String?) { + pendingTitle = title.orEmpty() + pendingTour = tour.orEmpty() + } + + /** Aktualisiert die Notification eines bereits laufenden ManualPlaybackService. */ + fun updateNotificationMeta(context: Context, title: String?, tour: String?) { + pendingTitle = title.orEmpty() + pendingTour = tour.orEmpty() + if (mediaPlayer != null) { + ManualPlaybackService.updateMeta( + context.applicationContext, title, tour + ) + } + } + // ------------------------------------------------------------------------- // Pause / Stop / Release // ------------------------------------------------------------------------- @@ -206,6 +261,21 @@ class ManualAudioPlayer { } } + /** + * Resume a previously paused playback at the same position. Used by the + * PTT interruption flow to bring back a waypoint audio that was paused + * because the user pressed PTT. If the MediaPlayer has been released or is + * already playing, this is a no-op. + */ + fun resume() { + runCatching { + val mp = mediaPlayer ?: return + if (!mp.isPlaying) mp.start() + }.onFailure { e -> + Log.e(TAG, "Fehler beim Fortsetzen", e) + } + } + /** * Stop and release resources. Clears current track and single-mode state. */ @@ -231,6 +301,9 @@ class ManualAudioPlayer { currentUri = null isSingleMode = false singleWaypointId = null + // v2.5.10 — Foreground-Service stoppen, sobald keine manuelle + // Wiedergabe mehr existiert. WakeLock wird vom Service freigegeben. + lastAppContext?.let { ctx -> ManualPlaybackService.stop(ctx) } } companion object { diff --git a/app/src/main/kotlin/de/waypointaudio/service/ManualPlaybackService.kt b/app/src/main/kotlin/de/waypointaudio/service/ManualPlaybackService.kt new file mode 100644 index 0000000..f04af2d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/ManualPlaybackService.kt @@ -0,0 +1,224 @@ +package de.waypointaudio.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import de.waypointaudio.MainActivity +import de.waypointaudio.R + +/** + * v2.5.10 — Foreground-Service mit Typ `mediaPlayback`, der die manuelle + * Wiedergabe (Tour-/Track-Play aus dem Waypoint-Listenmodus heraus) am + * Leben hält. + * + * **Problem in v2.5.8 und davor** + * =============================== + * `ManualAudioPlayer` benutzt einen einfachen [android.media.MediaPlayer], + * aber ohne dedizierten Foreground-Service. Sobald der Bildschirm sich + * abschaltet (Doze, Light-Doze, App-Standby), wird die App nach ca. 30 + * Minuten in den Cached-Zustand verschoben, der CPU-Scheduler bremst und + * MediaPlayer fällt schließlich aus. + * + * **Lösung in v2.5.10** + * ====================== + * Sobald die manuelle Wiedergabe startet (`ManualAudioPlayer.play(...)` oder + * `playSingle(...)`), wird dieser Service über [start] gestartet. Er: + * + * - ruft `startForeground(...)` mit Typ `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` + * auf — das ist die Voraussetzung für stabile Hintergrund-Wiedergabe + * auf Android 12+/14; + * - hält einen `PowerManager.PARTIAL_WAKE_LOCK`, damit Doze den CPU-Takt + * nicht zurückfährt; + * - zeigt eine niederpriore Notification mit Titel/Artist, die der + * bestehenden Atmo-Notification ähnelt. + * + * **Wichtig: keine Player-Ownership im Service** + * =============================================== + * Der eigentliche [android.media.MediaPlayer] bleibt — wie bisher — im + * [ManualAudioPlayer] der ViewModel-Schicht. Der Service ist nur der + * "Lebenshalter" und tritt mit dem Player NICHT in Interaktion. Das hält + * die Architektur einfach und vermeidet Regressionen in PTT-Interruption- + * und Atmo-Koexistenz-Logik, die alle gegen [ManualAudioPlayer] geschrieben + * wurden. + * + * Der Service stoppt sich selbst auf [ACTION_STOP] oder beim Service- + * destroy. WakeLock wird zwingend in [onDestroy] freigegeben. + */ +class ManualPlaybackService : Service() { + + companion object { + const val ACTION_START = "de.waypointaudio.manual.ACTION_START" + const val ACTION_STOP = "de.waypointaudio.manual.ACTION_STOP" + const val ACTION_UPDATE_META = "de.waypointaudio.manual.ACTION_UPDATE_META" + + const val EXTRA_TITLE = "manual_title" + const val EXTRA_TOUR = "manual_tour" + + private const val TAG = "ManualPlaybackService" + private const val NOTIFICATION_ID = 1201 + const val CHANNEL_ID = "manual_playback_channel" + + /** + * Startet den Service, falls noch nicht aktiv, und aktualisiert + * Titel/Tour-Anzeige in der Notification. + */ + fun start(context: Context, title: String?, tour: String?) { + val intent = Intent(context, ManualPlaybackService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_TITLE, title.orEmpty()) + putExtra(EXTRA_TOUR, tour.orEmpty()) + } + // startForegroundService MUSS auf Android O+ verwendet werden. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + /** Aktualisiert nur die Notification-Texte, ohne den Service neu zu starten. */ + fun updateMeta(context: Context, title: String?, tour: String?) { + val intent = Intent(context, ManualPlaybackService::class.java).apply { + action = ACTION_UPDATE_META + putExtra(EXTRA_TITLE, title.orEmpty()) + putExtra(EXTRA_TOUR, tour.orEmpty()) + } + try { context.startService(intent) } catch (e: Exception) { + Log.w(TAG, "updateMeta failed: ${e.message}") + } + } + + fun stop(context: Context) { + val intent = Intent(context, ManualPlaybackService::class.java).apply { + action = ACTION_STOP + } + try { context.startService(intent) } catch (e: Exception) { + Log.w(TAG, "stop dispatch failed: ${e.message}") + } + } + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(CHANNEL_ID) == null) { + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Manuelle Wiedergabe", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Hält Tour-/Trackwiedergabe im Hintergrund stabil" + setShowBadge(false) + } + ) + } + } + } + } + + private var wakeLock: PowerManager.WakeLock? = null + private var currentTitle: String = "" + private var currentTour: String = "" + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + ensureChannel(this) + // Sofortige Foreground-Notification: System gewährt nur ~5s Frist + // nach startForegroundService(), bevor es eine ANR-artige Sanktion + // gibt — wir nutzen daher schon hier eine valide Minimal-Notification. + startForegroundCompat(buildNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + Log.d(TAG, "ACTION_STOP") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + ACTION_START, ACTION_UPDATE_META -> { + currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty() + currentTour = intent.getStringExtra(EXTRA_TOUR).orEmpty() + acquireWakeLockIfNeeded() + val nm = getSystemService(NotificationManager::class.java) + nm.notify(NOTIFICATION_ID, buildNotification()) + } + else -> { + // Re-Delivery / null-Intent: einfach am Leben bleiben. + acquireWakeLockIfNeeded() + } + } + return START_STICKY + } + + override fun onDestroy() { + releaseWakeLock() + super.onDestroy() + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun buildNotification(): Notification { + val pi = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val title = currentTitle.ifBlank { getString(R.string.app_name) } + val text = if (currentTour.isNotBlank()) "Tour: $currentTour" else "Wiedergabe läuft" + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_splash_logo) + .setContentTitle(title) + .setContentText(text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setContentIntent(pi) + .build() + } + + private fun acquireWakeLockIfNeeded() { + if (wakeLock?.isHeld == true) return + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "GPS2Audio:ManualPlayback" + ).also { lock -> + lock.setReferenceCounted(false) + // 4h Hard-Cap als Sicherheitsnetz, falls Stopp-Pfad jemals + // ausgelassen wird. Im Regelfall wird der Lock durch + // onDestroy/Release lange vorher freigegeben. + lock.acquire(4 * 60 * 60 * 1000L) + } + } + + private fun releaseWakeLock() { + runCatching { wakeLock?.takeIf { it.isHeld }?.release() } + wakeLock = null + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/PttRecorder.kt b/app/src/main/kotlin/de/waypointaudio/service/PttRecorder.kt new file mode 100644 index 0000000..f5d2ea9 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/PttRecorder.kt @@ -0,0 +1,178 @@ +package de.waypointaudio.service + +import android.annotation.SuppressLint +import android.content.Context +import android.media.MediaMetadataRetriever +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import de.waypointaudio.data.PttRecording +import de.waypointaudio.data.PttRecordingFormat +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + +/** + * Kapselt die MediaRecorder-basierte PTT-Aufnahme in eine M4A/AAC-Datei. + * + * Implementierungshinweise: + * - MediaRecorder belegt das Mikrofon exklusiv. Wenn parallel zu Live-Ausgabe + * (AudioRecord) aufgenommen werden soll, kann es auf einigen Geräten zu + * Konflikten kommen. v2.1.0 verwendet daher AudioSource.MIC und versucht, + * AudioRecord & MediaRecorder gleichzeitig zu betreiben. Schlägt das fehl, + * läuft die Aufnahme allein. + * - Die Datei wird in /recordings/ angelegt, ein FileProvider-URI + * wird daraus konstruiert. + */ +class PttRecorder(private val context: Context) { + + companion object { + private const val TAG = "PttRecorder" + private const val SAMPLE_RATE = 44_100 + private const val BIT_RATE = 96_000 + private const val CHANNEL_COUNT = 1 + } + + private var recorder: MediaRecorder? = null + private var currentFile: File? = null + private var currentId: String = "" + private var currentDisplayName: String = "" + private var currentFormat: PttRecordingFormat = PttRecordingFormat.M4A_AAC + private var startEpochMs: Long = 0L + + /** True wenn aktuell eine Aufnahme läuft. */ + val isRecording: Boolean + get() = recorder != null + + /** + * Startet eine neue Aufnahme. + * + * @param format Aufnahmeformat. WAV wird in v2.1.0 noch nicht unterstützt; + * als Fallback wird M4A_AAC verwendet. + * @return das angelegte File-Objekt oder null bei Fehler. + */ + @SuppressLint("MissingPermission") + fun start(format: PttRecordingFormat = PttRecordingFormat.M4A_AAC): File? { + if (recorder != null) { + Log.w(TAG, "start() aufgerufen, obwohl bereits eine Aufnahme läuft") + return currentFile + } + + val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() } + // WAV wird in v2.1.0 nicht unterstützt → Fallback auf M4A + val effectiveFormat = if (format == PttRecordingFormat.M4A_AAC) format + else PttRecordingFormat.M4A_AAC + val extension = if (effectiveFormat == PttRecordingFormat.M4A_AAC) "m4a" else "wav" + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val id = UUID.randomUUID().toString() + val baseName = "PTT_$timestamp" + val outFile = File(recordingsDir, "$baseName.$extension") + + val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + + return try { + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioChannels(CHANNEL_COUNT) + rec.setAudioSamplingRate(SAMPLE_RATE) + rec.setAudioEncodingBitRate(BIT_RATE) + rec.setOutputFile(outFile.absolutePath) + rec.prepare() + rec.start() + recorder = rec + currentFile = outFile + currentId = id + currentDisplayName = baseName + currentFormat = effectiveFormat + startEpochMs = System.currentTimeMillis() + Log.i(TAG, "Aufnahme gestartet → ${outFile.absolutePath}") + outFile + } catch (e: Exception) { + Log.e(TAG, "Aufnahme konnte nicht gestartet werden", e) + runCatching { rec.release() } + recorder = null + currentFile = null + try { if (outFile.exists()) outFile.delete() } catch (_: Exception) {} + null + } + } + + /** + * Stoppt die Aufnahme und liefert die fertige PttRecording-Metadaten zurück. + * @param latitude Optionaler Breitengrad zum Zeitpunkt der Aufnahme + * @param longitude Optionaler Längengrad zum Zeitpunkt der Aufnahme + * @return Metadaten oder null wenn keine Aufnahme aktiv war oder ein Fehler auftrat. + */ + fun stop(latitude: Double? = null, longitude: Double? = null): PttRecording? { + val rec = recorder ?: return null + val file = currentFile ?: return null + + return try { + rec.stop() + rec.release() + recorder = null + val duration = readDurationMs(file) + val uriString = "file://${file.absolutePath}" + PttRecording( + id = currentId, + displayName = currentDisplayName, + filePath = file.absolutePath, + uri = uriString, + format = file.extension.ifBlank { "m4a" }, + createdAt = startEpochMs, + durationMs = duration, + latitude = latitude, + longitude = longitude + ) + } catch (e: Exception) { + Log.e(TAG, "Fehler beim Stoppen der Aufnahme", e) + runCatching { rec.release() } + recorder = null + // Datei verwerfen wenn sie zu klein/leer ist + try { + if (file.exists() && file.length() < 1024L) file.delete() + } catch (_: Exception) {} + null + } finally { + currentFile = null + currentId = "" + currentDisplayName = "" + startEpochMs = 0L + } + } + + /** Bricht die laufende Aufnahme ab und löscht die Datei. */ + fun cancel() { + val rec = recorder + val file = currentFile + recorder = null + currentFile = null + currentId = "" + currentDisplayName = "" + startEpochMs = 0L + runCatching { rec?.stop() } + runCatching { rec?.release() } + try { if (file != null && file.exists()) file.delete() } catch (_: Exception) {} + } + + private fun readDurationMs(file: File): Long { + return runCatching { + MediaMetadataRetriever().use { mmr -> + mmr.setDataSource(file.absolutePath) + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + } + }.getOrElse { 0L } + } + + private inline fun MediaMetadataRetriever.use(block: (MediaMetadataRetriever) -> R): R { + return try { block(this) } finally { runCatching { release() } } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/WakeLockController.kt b/app/src/main/kotlin/de/waypointaudio/service/WakeLockController.kt new file mode 100644 index 0000000..7a142dc --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/WakeLockController.kt @@ -0,0 +1,99 @@ +package de.waypointaudio.service + +import android.app.Activity +import android.util.Log +import android.view.WindowManager +import de.waypointaudio.data.WakeLockMode +import de.waypointaudio.data.WakeLockSettings + +/** + * Steuert das Wachhalten des Displays für eine Activity. + * + * Setzt [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] auf das Activity-Fenster, + * das vom Android-System verwaltet wird – das Flag wird automatisch gelöst, sobald + * die Activity in den Hintergrund (onPause/onStop) wechselt. + * + * FLAG_KEEP_SCREEN_ON ist die offiziell empfohlene Lösung + * (siehe https://developer.android.com/training/scheduling/wakelock): + * - Verbraucht weniger Akku als ein expliziter PowerManager-WakeLock + * - Wird automatisch beim Verlassen der App freigegeben + * - Funktioniert ab API 1, keine spezielle Permission nötig + * + * Die WAKE_LOCK-Berechtigung ist trotzdem im Manifest, weil sie auch von + * MediaPlayer / ExoPlayer (Audio-Wiedergabe im Service) benötigt wird. + */ +class WakeLockController(private val activity: Activity) { + + companion object { + private const val TAG = "WakeLockController" + } + + /** Letzter angewandter Modus & Zustand "Tour läuft" – für UI-Status. */ + var currentMode: WakeLockMode = WakeLockMode.NEVER + private set + + private var tourActive: Boolean = false + private var attached: Boolean = false + + /** + * Aktualisiert den Modus aus den persistierten Einstellungen. + * Wendet den Effekt sofort an, sofern die Activity attached ist. + */ + fun applySettings(settings: WakeLockSettings) { + currentMode = settings.mode + reevaluate() + } + + /** Wird vom Service-/ViewModel-State aufgerufen, sobald sich der Tour-/GPS-Status ändert. */ + fun setTourActive(active: Boolean) { + if (tourActive == active) return + tourActive = active + reevaluate() + } + + /** onResume / onStart einer Activity: Flag ggf. setzen. */ + fun onActivityResumed() { + attached = true + reevaluate() + } + + /** onPause / onStop einer Activity: Flag aufheben. */ + fun onActivityPaused() { + attached = false + clearFlag() + } + + private fun shouldKeepOn(): Boolean = when (currentMode) { + WakeLockMode.NEVER -> false + WakeLockMode.APP_FOREGROUND -> true + WakeLockMode.ACTIVE_TOUR -> tourActive + } + + private fun reevaluate() { + if (!attached) { + clearFlag() + return + } + if (shouldKeepOn()) setFlag() else clearFlag() + } + + private fun setFlag() { + runCatching { + activity.runOnUiThread { + activity.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + }.onFailure { e -> + Log.w(TAG, "Konnte KEEP_SCREEN_ON nicht aktivieren", e) + } + } + + private fun clearFlag() { + runCatching { + activity.runOnUiThread { + activity.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + }.onFailure { e -> + Log.w(TAG, "Konnte KEEP_SCREEN_ON nicht entfernen", e) + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/WaypointAudioInterruptionCoordinator.kt b/app/src/main/kotlin/de/waypointaudio/service/WaypointAudioInterruptionCoordinator.kt new file mode 100644 index 0000000..fe5420f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/WaypointAudioInterruptionCoordinator.kt @@ -0,0 +1,156 @@ +package de.waypointaudio.service + +import android.util.Log + +/** + * Prozessweiter Singleton, der alle aktuell aktiven Wegpunkt-Audio-Quellen + * (GPS-getriggert über [AudioPlayer], manuell über [ManualAudioPlayer]) kennt + * und sie bei einer Unterbrechung (aktuell: PTT) zentral pausieren bzw. nach + * deren Ende fortsetzen kann. + * + * Designziele (v2.0.3): + * - PTT und Wegpunkt-Audio werden nie gleichzeitig hörbar. + * - Wenn vor PTT-Start kein Wegpunkt-Audio lief, passiert nichts. + * - Wenn ein Wegpunkt-Audio lief, wird es zu Beginn von PTT pausiert und + * nach Ende von PTT genau wieder fortgesetzt (gleiche Position). + * - Doppelte PTT-Start- / Stop-Events erzeugen keine wiederholten pause/resume- + * Aufrufe (Idempotenz). + * - Es entstehen keine UI-Änderungen. + * + * Architektur: + * Jede laufende Wegpunkt-Audio-Komponente registriert sich beim Start ihrer + * Wiedergabe mit einer [Source] und stellt zwei Lambdas (pause/resume) sowie + * einen Live-Check (isCurrentlyPlaying) bereit. Beim Stoppen oder natürlichen + * Ende deregistriert sie sich wieder. Der Coordinator behält pro Source nur + * einen Eintrag. + */ +object WaypointAudioInterruptionCoordinator { + + private const val TAG = "WaypointAudioInterrupt" + + /** Mögliche Quellen einer Wegpunkt-Audio-Wiedergabe. */ + enum class Source { GPS, MANUAL } + + private data class Registration( + val isCurrentlyPlaying: () -> Boolean, + val pause: () -> Unit, + val resume: () -> Unit + ) + + /** Registrierte aktive Quellen (zu jeder Source max. eine Registrierung). */ + private val registrations = mutableMapOf() + + /** Quellen, die bei der laufenden Unterbrechung tatsächlich pausiert wurden. */ + private val pausedDuringInterruption = mutableSetOf() + + /** Aktuell aktive Unterbrechung? (verhindert wiederholtes pause/resume) */ + private var interruptionActive: Boolean = false + + /** + * Registriert eine aktive Audio-Quelle. Wird typischerweise direkt nach + * dem Start der Wiedergabe aufgerufen. Eine eventuell zuvor registrierte + * Registrierung derselben Quelle wird ersetzt. + */ + @Synchronized + fun register( + source: Source, + isCurrentlyPlaying: () -> Boolean, + pause: () -> Unit, + resume: () -> Unit + ) { + registrations[source] = Registration(isCurrentlyPlaying, pause, resume) + Log.d(TAG, "Quelle registriert: $source (aktive: ${registrations.keys})") + + // Sonderfall: PTT ist bereits aktiv und die Quelle startet trotzdem + // (z. B. eine GPS-Wiedergabe, die zwischen den States kollidiert). Damit + // PTT und Wegpunkt nicht doppelt hörbar werden, sofort pausieren und für + // den späteren Resume vormerken. + if (interruptionActive && isCurrentlyPlaying()) { + runCatching { pause() }.onFailure { + Log.w(TAG, "Pause der frisch registrierten Quelle $source fehlgeschlagen", it) + } + pausedDuringInterruption.add(source) + Log.d(TAG, "Quelle $source bei laufender Unterbrechung sofort pausiert") + } + } + + /** + * Deregistriert eine zuvor registrierte Quelle. Wird beim natürlichen + * Ende, beim expliziten Stop oder bei Tour-/Player-Wechseln aufgerufen. + * + * Wenn die Quelle während einer aktiven Unterbrechung deregistriert wird, + * wird sie aus dem [pausedDuringInterruption]-Set entfernt – sie wird also + * nicht erneut "fortgesetzt", wenn die Unterbrechung endet. + */ + @Synchronized + fun unregister(source: Source) { + if (registrations.remove(source) != null) { + Log.d(TAG, "Quelle deregistriert: $source (verbleibend: ${registrations.keys})") + } + pausedDuringInterruption.remove(source) + } + + /** + * Beginnt eine Unterbrechung (z. B. PTT-Start). + * Alle aktuell tatsächlich spielenden Quellen werden pausiert und für den + * späteren Resume vermerkt. Wiederholte Aufrufe ohne zwischenzeitliches + * [endInterruption] sind No-Ops. + */ + @Synchronized + fun beginInterruption() { + if (interruptionActive) { + Log.d(TAG, "beginInterruption ignoriert – bereits aktiv") + return + } + interruptionActive = true + pausedDuringInterruption.clear() + + registrations.forEach { (source, reg) -> + val playing = runCatching { reg.isCurrentlyPlaying() }.getOrDefault(false) + if (playing) { + runCatching { reg.pause() }.onFailure { + Log.w(TAG, "Pause für $source fehlgeschlagen", it) + } + pausedDuringInterruption.add(source) + Log.d(TAG, "Wegpunkt-Audio pausiert wegen Unterbrechung: $source") + } + } + if (pausedDuringInterruption.isEmpty()) { + Log.d(TAG, "Keine aktive Wegpunkt-Audio-Quelle – nichts zu pausieren") + } + } + + /** + * Beendet eine Unterbrechung (z. B. PTT-Stop). + * Quellen, die zu Beginn der Unterbrechung tatsächlich pausiert wurden und + * noch registriert sind, werden fortgesetzt. Wiederholte Aufrufe ohne + * passendes [beginInterruption] sind No-Ops. + */ + @Synchronized + fun endInterruption() { + if (!interruptionActive) { + Log.d(TAG, "endInterruption ignoriert – nicht aktiv") + return + } + interruptionActive = false + + val toResume = pausedDuringInterruption.toList() + pausedDuringInterruption.clear() + + toResume.forEach { source -> + val reg = registrations[source] + if (reg == null) { + Log.d(TAG, "Resume übersprungen: Quelle $source nicht mehr registriert") + return@forEach + } + runCatching { reg.resume() }.onFailure { + Log.w(TAG, "Resume für $source fehlgeschlagen", it) + } + Log.d(TAG, "Wegpunkt-Audio fortgesetzt nach Unterbrechung: $source") + } + } + + /** Gibt an, ob aktuell eine Unterbrechung läuft (für Tests / Logging). */ + @Synchronized + fun isInterrupting(): Boolean = interruptionActive +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt b/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt index ef1383a..4e48a43 100644 --- a/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt +++ b/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt @@ -23,6 +23,7 @@ import de.waypointaudio.data.PlaybackMode import de.waypointaudio.data.TourAudioSettings import de.waypointaudio.data.TourMusicStore import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.activeClips import de.waypointaudio.repository.WaypointRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -157,6 +158,9 @@ class WaypointLocationService : Service() { private fun stopTracking() { fusedLocationClient.removeLocationUpdates(locationCallback) audioPlayer.stop() + WaypointAudioInterruptionCoordinator.unregister( + WaypointAudioInterruptionCoordinator.Source.GPS + ) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() Log.i(TAG, "GPS-Tracking gestoppt") @@ -206,9 +210,10 @@ class WaypointLocationService : Service() { val msg = getString(R.string.notification_waypoint_reached, waypoint.name) updateNotification(msg) - // Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt - if (waypoint.soundUri.isBlank()) { - Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei") + // v2.5.12 — Effektive Clipliste prüfen. activeClips() berücksichtigt + // sowohl das neue audioClips-Modell als auch Legacy-soundUri. + if (waypoint.activeClips().isEmpty()) { + Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine aktiven Audioclips") return } @@ -246,21 +251,28 @@ class WaypointLocationService : Service() { /** * Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz). * Kann direkt oder nach PTT-Ende aufgerufen werden. + * + * v2.5.12 — Multi-Clip: + * - Es werden ALLE aktiven Clips des Wegpunkts der Reihe nach abgespielt. + * - Inaktive Clips werden übersprungen (siehe [Waypoint.activeClips]). + * - Begleitmusik wird einmalig vor dem ersten Clip pausiert/geduckt + * und einmalig nach dem letzten Clip wiederhergestellt — nicht zwischen + * den Clips, damit die Sequenz nahtlos wirkt. + * - playCount wird genau einmal pro Eintrittsereignis erhöht, nachdem + * der letzte Clip natürlich endete (keine Erhöhung bei Fehler). + * - Bei einem Fehler eines Clips wird abgebrochen, Begleitmusik wieder + * hergestellt; playCount bleibt unverändert. */ private fun playWaypointAudio(waypoint: Waypoint) { - // Begleitmusik-Sequenz: - // 1. beforeWaypointAudio() - Begleitmusik pausieren/ducken/faden - // 2. audioPlayer.play() - Wegpunkt-Ton starten - // 3a. onCompletion - nach naturalem Ende: afterWaypointAudio() + playCount++ - // 3b. onError - bei Fehler: afterWaypointAudio() (kein playCount++) - // - // Alles laeuft im Main-Dispatcher, da ExoPlayer (Begleitmusik) Main erfordert. - // Die Callbacks von AudioPlayer kommen auf dem MediaPlayer-Thread und werden - // per serviceScope.launch(Dispatchers.Main) weitergeleitet. - serviceScope.launch(Dispatchers.Main) { val mgr = musicManager + val clipUris = waypoint.activeClips().map { it.uri } + if (clipUris.isEmpty()) { + Log.d(TAG, "playWaypointAudio: keine aktiven Clips für '${waypoint.name}'") + return@launch + } + // Tour-Einstellungen laden val settings: TourAudioSettings? = if (mgr != null) { runCatching { @@ -269,7 +281,7 @@ class WaypointLocationService : Service() { }.getOrNull() } else null - // 1. Begleitmusik vor Wegpunkt-Audio anpassen + // 1. Begleitmusik einmalig vor erstem Clip anpassen if (mgr != null && settings != null && settings.enabled) { runCatching { mgr.currentSettings = settings @@ -279,35 +291,85 @@ class WaypointLocationService : Service() { } } - // 2. Wegpunkt-Ton abspielen - // onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen; - // wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe. - audioPlayer.play( - context = applicationContext, - soundUri = waypoint.soundUri, - onCompletion = { - // Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen - serviceScope.launch(Dispatchers.Main) { - Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}") - restoreBackgroundMusic(mgr, settings) - } - // playCount auf IO-Thread inkrementieren (Repository-Zugriff) - serviceScope.launch(Dispatchers.IO) { - runCatching { - repository.incrementPlayCount(waypoint.id) - }.onFailure { e -> - Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e) - } - } - }, - onError = { e -> - // Fehler: Begleitmusik trotzdem wiederherstellen; kein playCount++ - serviceScope.launch(Dispatchers.Main) { - Log.w(TAG, "Wegpunkt-Ton Fehler (${waypoint.name}): ${e?.message}") - restoreBackgroundMusic(mgr, settings) - } - } + // 2. Beim PTT-Coordinator registrieren – über die gesamte + // Multi-Clip-Sequenz hinweg. Pause/Resume betreffen immer + // den aktuell aktiven MediaPlayer im AudioPlayer-Wrapper. + WaypointAudioInterruptionCoordinator.register( + source = WaypointAudioInterruptionCoordinator.Source.GPS, + isCurrentlyPlaying = { audioPlayer.isPlaying }, + pause = { audioPlayer.pause() }, + resume = { audioPlayer.resume() } ) + + // 3. Clips sequenziell abspielen. + playClipSequence( + waypoint = waypoint, + clipUris = clipUris, + index = 0, + mgr = mgr, + settings = settings + ) + } + } + + /** + * v2.5.12 — Spielt den Clip mit Index [index] und ruft sich selbst nach + * natürlichem Ende für den nächsten Clip auf. Bei Fehler oder am Ende + * der Liste wird die Begleitmusik wiederhergestellt; der Coordinator + * abgemeldet; und bei natürlichem Ende der gesamten Sequenz wird + * playCount inkrementiert. + */ + private fun playClipSequence( + waypoint: Waypoint, + clipUris: List, + index: Int, + mgr: BackgroundMusicManager?, + settings: TourAudioSettings? + ) { + if (index !in clipUris.indices) { + // Sequenz erfolgreich durchlaufen + finishSequence(waypoint, mgr, settings, incrementPlayCount = true) + return + } + + val uri = clipUris[index] + audioPlayer.play( + context = applicationContext, + soundUri = uri, + onCompletion = { + serviceScope.launch(Dispatchers.Main) { + Log.d(TAG, "Clip ${index + 1}/${clipUris.size} beendet (${waypoint.name})") + playClipSequence(waypoint, clipUris, index + 1, mgr, settings) + } + }, + onError = { e -> + Log.w(TAG, "Clip ${index + 1}/${clipUris.size} Fehler (${waypoint.name}): ${e?.message}") + // Bei Fehler abbrechen, Begleitmusik wiederherstellen, KEIN playCount++ + serviceScope.launch(Dispatchers.Main) { + finishSequence(waypoint, mgr, settings, incrementPlayCount = false) + } + } + ) + } + + private fun finishSequence( + waypoint: Waypoint, + mgr: BackgroundMusicManager?, + settings: TourAudioSettings?, + incrementPlayCount: Boolean + ) { + WaypointAudioInterruptionCoordinator.unregister( + WaypointAudioInterruptionCoordinator.Source.GPS + ) + restoreBackgroundMusic(mgr, settings) + if (incrementPlayCount) { + serviceScope.launch(Dispatchers.IO) { + runCatching { + repository.incrementPlayCount(waypoint.id) + }.onFailure { e -> + Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e) + } + } } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt index a565478..c57d895 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt @@ -1,34 +1,313 @@ package de.waypointaudio.ui import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import android.os.ParcelFileDescriptor +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.waypointaudio.R +import de.waypointaudio.license.DeviceIdProvider +import de.waypointaudio.license.LicenseStatusProvider +import de.waypointaudio.util.AppVersion +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -/** - * Info-Dialog: Zeigt App-Name, Beschreibung, Entwickler, E-Mail, Version - * und eine kurze Zusammenfassung der Apache License 2.0. - */ +// Info-Dialog: Zeigt App-Name, Lizenz-/Pro-Bereich mit Lizenzstatus, Versions-/Entwicklerinfos. +// +// v2.5.28 - Profi Lizenztext: +// - Profi-Sektion: Abo-Wording vollständig entfernt. Stattdessen korrekte +// Formulierung einer einjährigen Lizenz mit aktiver Erneuerungsoption. +// - Keine automatische Verlängerung / keine Abo-Vertragswirkung mehr suggeriert. +// - Features-Text neu strukturiert: kompakte Bullet-Liste statt Fließtext. +// - Neue Strings: about_pro_tagline2, about_pro_renewal_note, about_pro_features_list. +// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.27 unverändert. +// +// v2.5.27 - Profi Abo Mailfix: +// - Empfängeradresse geändert von m3lmayer@protonmail.com auf marcel.mayer@nesohub.org. +// - Mail-Intent robuster: ACTION_SENDTO mit vollständiger mailto-URI (Empfänger, Subject, +// Body vollständig URL-encoded via java.net.URLEncoder direkt in Uri.parse("mailto:...")). +// Empfänger ist damit zuverlässig gesetzt, unabhängig von E-Mail-App-Verhalten. +// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.26 unverändert. +// +// v2.5.26 - License-Page-Cleanup: +// - Preis (555 €) und PayPal-Button aus der About-Seite entfernt. +// - Neuer E-Mail-Button "Profi-Lizenz per E-Mail anfragen" mit vorbefüllter +// E-Mail (ACTION_SENDTO, mailto:), inkl. Geräte-ID automatisch eingefügt. +// - Fallback-Toast wenn keine E-Mail-App verfügbar ist. +// - Pro-Sektion verschlankt: Tagline + Features-Text, kompaktere Struktur. +// - LicenseStatusProvider: humanLicenseType() erkennt jetzt auch "pro"/"profi" +// als Anzeigename "Profi" (nur Anzeige, kein Formatänderung). +// - Alle technischen Lizenzimport-/Prüf-Mechanismen aus v2.5.25 unverändert. +// +// v2.5.25 - License-Import-Thread-Fix: +// - Alle blockierenden I/O-Operationen (contentResolver.openInputStream, +// openFileDescriptor, FileInputStream, ByteArrayOutputStream.copyTo, +// LicenseStatusProvider.import inkl. JSON/Signaturvalidierung) laufen jetzt +// auf Dispatchers.IO, nicht mehr auf dem Main-Thread. +// - ActivityResult-Callback startet eine Coroutine via rememberCoroutineScope(). +// withContext(Dispatchers.IO) { readLicenseBytes(...) + LicenseStatusProvider.import() } +// Danach werden UI-State/Toast/reloadTrigger wieder auf dem Main-Thread gesetzt. +// - isImporting-Flag verhindert Doppelauslösung des Import-Buttons. +// - exportLauncher ebenfalls auf Dispatchers.IO verschoben. +// - Textimport: LicenseStatusProvider.import() jetzt ebenfalls auf Dispatchers.IO. +// - Alle v2.5.24-Fallbacks (drei Lesestrategien, Diagnose-Dialog, Textimport, +// OpenDocument */*, importNonce) unverändert beibehalten. +// +// v2.5.24 - Lizenzimport-Fallbacks: +// 1. readLicenseBytes() mit drei Lesestrategien: +// a) openInputStream(uri) -> copyTo(ByteArrayOutputStream) +// b) openFileDescriptor(uri, "r") -> FileInputStream(fd) +// c) uri.scheme=="file" -> File(uri.path).inputStream() +// 2. OpenDocument() mit nur "*/" + "*" (d.h. */*) - keine MIME-Liste, verhindert Galerie-Modus. +// 3. importNonce: unveraendert aus v2.5.23. +// 4. Textimport-Fallback: AlertDialog mit JSON-Textfeld + Zwischenablage-Button. +// 5. Diagnose-Dialog statt generischem Toast bei Totalausfall aller Lesestrategien. +// v2.5.23 - Regression-Fix OpenDocument. +// v2.5.21 - Re-Import-Bugfix + Geraete-ID. +// v2.5.20 - Lizenzaktionen. @Composable fun AboutDialog(onDismiss: () -> Unit) { val context = LocalContext.current + val versionInfo = remember(context) { AppVersion.get(context) } + val scope = rememberCoroutineScope() + + // reloadTrigger: Inkrementieren erzwingt Recomposition der Lizenzstatus-Card. + var reloadTrigger by remember { mutableIntStateOf(0) } + + // importNonce: Inkrementieren erzeugt neuen Launcher-Slot für Reimport derselben Datei. + var importNonce by remember { mutableIntStateOf(0) } + + // v2.5.25 — Import läuft: verhindert Doppelauslösung während I/O auf IO-Dispatcher. + var isImporting by remember { mutableStateOf(false) } + + // Zustände für Fehlerdiagnose-Dialog + var showDiagDialog by remember { mutableStateOf(false) } + var diagMessage by remember { mutableStateOf("") } + + // Zustand für Textimport-Dialog + var showTextImport by remember { mutableStateOf(false) } + + LaunchedEffect(reloadTrigger) { + LicenseStatusProvider.ensureLoaded(context) + } + + val showBgReliability = remember { mutableStateOf(false) } + if (showBgReliability.value) { + BackgroundReliabilityManualDialog(onClose = { showBgReliability.value = false }) + } + + var showRemoveConfirm by remember { mutableStateOf(false) } + + // v2.5.24 — Diagnose-Dialog: zeigt technische Fehlerdetails, wenn alle Lesestrategien scheitern. + if (showDiagDialog) { + AlertDialog( + onDismissRequest = { showDiagDialog = false }, + title = { Text(stringResource(R.string.about_license_import_err_diag_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.about_license_import_err_diag_intro), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = diagMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Text( + text = stringResource(R.string.about_license_import_err_diag_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + }, + confirmButton = { + TextButton(onClick = { showDiagDialog = false }) { + Text(stringResource(R.string.about_close)) + } + } + ) + } + + // v2.5.24 — Textimport-Dialog: Nutzer fügt JSON-Inhalt der Lizenzdatei ein. + if (showTextImport) { + LicenseTextImportDialog( + context = context, + onDismiss = { showTextImport = false }, + onImported = { + reloadTrigger++ + showTextImport = false + } + ) + } + + // v2.5.25 — Import-Launcher mit Dispatchers.IO-Fix. + // + // v2.5.24 — Import-Launcher: OpenDocument() mit nur "*/*" (maximale Kompatibilität). + // + // Begründung für nur "*/*": + // - Mehrere MIME-Typen (json, text/*, octet-stream, */*) können auf einigen + // MIUI-Versionen dazu führen, dass der Picker in einen Galerie/Medien-Modus + // wechselt. Nur "*/*" erzwingt immer den echten Datei-Browser. + // - OpenDocument() bleibt (nicht GetContent): echter SAF-Datei-Browser, zeigt + // alle Dateien inkl. .gps2audio-license. + // + // v2.5.25 — I/O auf Dispatchers.IO: + // - readLicenseBytes() (openInputStream/openFileDescriptor/File-Lesen) läuft + // jetzt vollständig auf Dispatchers.IO. + // - LicenseStatusProvider.import() (inkl. JSON-Parsing + Signaturprüfung) + // ebenfalls auf Dispatchers.IO. + // - UI-State (diagMessage, showDiagDialog, Toast, reloadTrigger, isImporting) + // wird danach auf dem Main-Thread gesetzt. + val importLauncher = key(importNonce) { + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + // Immer erhöhen: nächster Import (auch gleiche Datei) bekommt frischen Launcher. + importNonce++ + + if (uri == null) { + // Nutzer hat Picker abgebrochen. + return@rememberLauncherForActivityResult + } + + // v2.5.25 — Alle blockierenden Operationen auf Dispatchers.IO verschieben. + isImporting = true + scope.launch { + // --- Dispatchers.IO: readLicenseBytes + LicenseStatusProvider.import --- + val (bytes, errMsg) = withContext(Dispatchers.IO) { + readLicenseBytes(context, uri) + } + + if (bytes == null) { + // Alle Strategien gescheitert → Diagnose-Dialog anzeigen (Main-Thread) + diagMessage = errMsg ?: context.getString(R.string.about_license_import_err_open) + showDiagDialog = true + isImporting = false + return@launch + } + + if (bytes.isEmpty()) { + Toast.makeText( + context, + context.getString(R.string.about_license_import_err_empty), + Toast.LENGTH_LONG + ).show() + isImporting = false + return@launch + } + + // LicenseStatusProvider.import: JSON-Parsing + Signaturvalidierung auf IO + val result = withContext(Dispatchers.IO) { + LicenseStatusProvider.import(context, bytes) + } + + // Zurück auf Main-Thread: UI-State setzen + val msg = if (result.success) { + context.getString(R.string.about_license_import_success, result.message) + } else { + context.getString(R.string.about_license_import_fail_invalid, result.message) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + reloadTrigger++ + isImporting = false + } + } + } + + // v2.5.25 — Export ebenfalls auf Dispatchers.IO (openOutputStream ist blocking). + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json") + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + val raw = withContext(Dispatchers.IO) { + LicenseStatusProvider.exportRawJson(context) + } + if (raw == null) { + Toast.makeText( + context, + context.getString(R.string.about_license_export_nothing), + Toast.LENGTH_LONG + ).show() + return@launch + } + val ok = withContext(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { + it.write(raw.toByteArray(Charsets.UTF_8)) + true + } ?: false + } catch (_: Throwable) { + false + } + } + val msg = if (ok) { + context.getString(R.string.about_license_export_success) + } else { + context.getString(R.string.about_license_export_fail) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + + if (showRemoveConfirm) { + AlertDialog( + onDismissRequest = { showRemoveConfirm = false }, + title = { Text(stringResource(R.string.about_license_action_remove_confirm_title)) }, + text = { Text(stringResource(R.string.about_license_action_remove_confirm_text)) }, + confirmButton = { + TextButton(onClick = { + val ok = LicenseStatusProvider.remove(context) + val msg = if (ok) { + context.getString(R.string.about_license_action_remove_done) + } else { + context.getString(R.string.about_license_action_remove_fail) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + showRemoveConfirm = false + reloadTrigger++ + importNonce++ + }) { Text(stringResource(R.string.about_license_action_remove_confirm_ok)) } + }, + dismissButton = { + TextButton(onClick = { showRemoveConfirm = false }) { + Text(stringResource(R.string.about_license_action_remove_confirm_cancel)) + } + } + ) + } AlertDialog( onDismissRequest = onDismiss, @@ -45,7 +324,6 @@ fun AboutDialog(onDismiss: () -> Unit) { .padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // App name + description Text( text = stringResource(R.string.about_app_name), style = MaterialTheme.typography.titleMedium, @@ -56,21 +334,378 @@ fun AboutDialog(onDismiss: () -> Unit) { style = MaterialTheme.typography.bodyMedium ) - HorizontalDivider() + // v2.5.28 — Profi-Sektion: Abo-Wording entfernt, einjährige Lizenz formuliert. + // Keine automatische Verlängerung. Features als kompakte Bullet-Liste. + // Geräte-ID wird automatisch in den E-Mail-Body eingefügt. + val proRequestCd = stringResource(R.string.about_pro_request_open_cd) + val proEmailRecipient = stringResource(R.string.about_pro_email_recipient) + val proEmailSubject = stringResource(R.string.about_pro_email_subject) + val proEmailBodyTemplate = stringResource(R.string.about_pro_email_body_template) + val proEmailNoApp = stringResource(R.string.about_pro_email_no_app) + val proDeviceId = remember(context) { DeviceIdProvider.getDeviceIdHash(context) } + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.about_pro_section_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.75f) + ) + Text( + text = stringResource(R.string.about_pro_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + // v2.5.28 — Einjährige Lizenz (kein Abo-Wording) + Text( + text = stringResource(R.string.about_pro_tagline), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_tagline2), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_renewal_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) + ) + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) + ) + // v2.5.28 — Profi-Features als kompakte Bullet-Liste + Text( + text = stringResource(R.string.about_pro_features_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) + ) + Text( + text = stringResource(R.string.about_pro_features_list), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) + ) + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) + ) + Text( + text = stringResource(R.string.about_pro_required_intro), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_required_line1), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_required_line2), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_required_line3), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_required_line4), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = stringResource(R.string.about_pro_private_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + Text( + text = stringResource(R.string.about_pro_manual_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + // v2.5.27 — Mail-Intent-Fix: vollständige mailto-URI mit Empfänger, + // Subject und Body direkt URL-encoded. Empfänger wird dadurch + // zuverlässig in jede E-Mail-App übertragen (kein extras-Verlust). + // ACTION_SENDTO + mailto: ist der korrekte Weg für direkte Mail-Apps. + Button( + onClick = { + val body = proEmailBodyTemplate.format(proDeviceId) + // URL-Encoding für Subject und Body (RFC 2368 / mailto-URI). + // java.net.URLEncoder.encode() kodiert Leerzeichen als '+', + // mailto erwartet jedoch '%20' — daher Replace. + val encodedSubject = java.net.URLEncoder.encode(proEmailSubject, "UTF-8") + .replace("+", "%20") + val encodedBody = java.net.URLEncoder.encode(body, "UTF-8") + .replace("+", "%20") + // Vollständige mailto-URI: Empfänger fest in URI kodiert, + // nicht nur als Extra → zuverlässig in allen Mail-Apps. + val mailtoUriString = "mailto:$proEmailRecipient?subject=$encodedSubject&body=$encodedBody" + val mailtoUri = Uri.parse(mailtoUriString) + val intent = Intent(Intent.ACTION_SENDTO, mailtoUri) + val launched = try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + if (!launched) { + // Fallback: Chooser (z. B. falls kein direkter Handler) + val fallback = Intent(Intent.ACTION_SENDTO, mailtoUri) + val chooser = Intent.createChooser(fallback, null) + val launched2 = try { + context.startActivity(chooser) + true + } catch (_: ActivityNotFoundException) { + false + } + if (!launched2) { + Toast.makeText(context, proEmailNoApp, Toast.LENGTH_LONG).show() + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = proRequestCd } + ) { + Text(text = stringResource(R.string.about_pro_request_button)) + } + Text( + text = stringResource(R.string.about_pro_contact_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + } + + // Lizenzstatus-Card mit reloadTrigger-gesteuerter Recomposition. + val licenseStatus = remember(reloadTrigger) { LicenseStatusProvider.status } + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.about_license_status_label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + AboutInlineRow( + label = stringResource(R.string.about_license_status_state_label), + value = licenseStatus.stateText + ) + licenseStatus.licenseTypeLabel?.let { + AboutInlineRow( + label = stringResource(R.string.about_license_type_label), + value = it + ) + } + AboutInlineRow( + label = stringResource(R.string.about_license_status_holder_label), + value = licenseStatus.holderText + ) + AboutInlineRow( + label = stringResource(R.string.about_license_status_validity_label), + value = licenseStatus.validityText + ) + AboutInlineRow( + label = stringResource(R.string.about_license_status_features_label), + value = licenseStatus.featuresText + ) + licenseStatus.bindingText?.let { + AboutInlineRow( + label = stringResource(R.string.about_license_binding_label), + value = it + ) + } + licenseStatus.licenseId?.let { + AboutInlineRow( + label = stringResource(R.string.about_license_id_label), + value = it + ) + } + if (licenseStatus.permissions.isNotEmpty()) { + Text( + text = stringResource(R.string.about_license_permissions_label) + ":", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + for ((key, value) in licenseStatus.permissions) { + val label = LicenseStatusProvider.humanPermissionLabel(key) + val mark = if (value) "✓" else "—" + Text( + text = " $mark $label", + style = MaterialTheme.typography.bodySmall + ) + } + } + licenseStatus.errorMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + Text( + text = stringResource(R.string.about_license_status_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // Aktionen + Text( + text = stringResource(R.string.about_license_actions_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // v2.5.25 — Import: OpenDocument() */* + Dispatchers.IO-Fix. + // isImporting verhindert Doppelauslösung während laufender I/O. + OutlinedButton( + onClick = { + if (!isImporting) { + try { + importLauncher.launch(arrayOf("*/*")) + } catch (_: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(R.string.about_license_import_err_no_app), + Toast.LENGTH_LONG + ).show() + } + } + }, + enabled = !isImporting, + modifier = Modifier.fillMaxWidth() + ) { + if (isImporting) { + Text(stringResource(R.string.about_license_action_import) + "…") + } else { + Text(stringResource(R.string.about_license_action_import)) + } + } + + // v2.5.24 — Fallback: Lizenztext manuell einfügen (für MIUI/OEM-Probleme) + OutlinedButton( + onClick = { showTextImport = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.about_license_action_paste)) + } + + val hasStored = remember(reloadTrigger) { + LicenseStatusProvider.hasStoredLicense(context) + } + if (hasStored) { + OutlinedButton( + onClick = { + try { + exportLauncher.launch( + context.getString(R.string.about_license_export_filename_default) + ) + } catch (_: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(R.string.about_license_export_fail), + Toast.LENGTH_LONG + ).show() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.about_license_action_export)) + } + OutlinedButton( + onClick = { showRemoveConfirm = true }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.about_license_action_remove)) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // v2.5.21 — Geräte-ID-Sektion (unverändert) + val deviceIdHash = remember(context) { DeviceIdProvider.getDeviceIdHash(context) } + val deviceIdShort = remember(deviceIdHash) { + if (deviceIdHash.length > 24) + "${deviceIdHash.take(16)}…${deviceIdHash.takeLast(8)}" + else deviceIdHash + } + Text( + text = stringResource(R.string.about_device_id_label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.about_device_id_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + AboutInlineRow( + label = stringResource(R.string.about_device_id_field_label), + value = deviceIdShort + ) + OutlinedButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) + as? ClipboardManager + if (clipboard != null) { + val clip = ClipData.newPlainText( + context.getString(R.string.about_device_id_copy_label), + deviceIdHash + ) + clipboard.setPrimaryClip(clip) + Toast.makeText( + context, + context.getString(R.string.about_device_id_copied), + Toast.LENGTH_SHORT + ).show() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.about_device_id_copy_button)) + } + } + } - // Version AboutRow( label = stringResource(R.string.about_version_label), - value = stringResource(R.string.about_version) + value = versionInfo.display ) - // Developer AboutRow( label = stringResource(R.string.about_developer_label), value = stringResource(R.string.about_developer) ) - // Email - preserved exactly as provided by developer AboutRow( label = stringResource(R.string.about_email_label), value = stringResource(R.string.about_email) @@ -78,7 +713,18 @@ fun AboutDialog(onDismiss: () -> Unit) { HorizontalDivider() - // License + Text( + text = stringResource(R.string.about_terms_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Text( + text = stringResource(R.string.about_terms_text), + style = MaterialTheme.typography.bodySmall + ) + + HorizontalDivider() + Text( text = stringResource(R.string.about_license_label), style = MaterialTheme.typography.labelLarge, @@ -99,74 +745,63 @@ fun AboutDialog(onDismiss: () -> Unit) { color = MaterialTheme.colorScheme.primary ) - HorizontalDivider() - - // Matrix contact Text( - text = stringResource(R.string.about_matrix_label), + text = stringResource(R.string.about_license_commons_clause_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Text( - text = stringResource(R.string.about_matrix_description), - style = MaterialTheme.typography.bodyMedium - ) - - // Matrix ID as selectable text - Text( - text = stringResource(R.string.about_matrix_id), + text = stringResource(R.string.about_license_commons_clause_name), style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.about_license_commons_clause_summary), + style = MaterialTheme.typography.bodySmall ) - // QR code - Box( - modifier = Modifier - .padding(top = 4.dp) - .background( - color = Color.White, - shape = MaterialTheme.shapes.small - ) - .padding(8.dp) - .align(Alignment.CenterHorizontally) - ) { - Image( - painter = painterResource(id = R.drawable.matrix_contact_qr), - contentDescription = stringResource(R.string.about_matrix_qr_content_description), - modifier = Modifier.size(200.dp) - ) - } + Text( + text = stringResource(R.string.about_third_party_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Text( + text = stringResource(R.string.about_third_party_text), + style = MaterialTheme.typography.bodySmall + ) - // Clickable button to open Matrix chat in browser or Matrix client - val openCd = stringResource(R.string.about_matrix_open_cd) - val matrixUrl = stringResource(R.string.about_matrix_url) - Button( - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse(matrixUrl) + Text( + text = stringResource(R.string.about_license_disclaimer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.bg_reliability_settings_entry), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold ) - try { - context.startActivity(intent) - } catch (e: ActivityNotFoundException) { - // Fallback: explicit chooser for browser - try { - context.startActivity(Intent.createChooser(intent, null)) - } catch (_: ActivityNotFoundException) { - // No browser/client available - silently ignore - } + Text( + text = stringResource(R.string.bg_reliability_settings_entry_subtitle), + style = MaterialTheme.typography.bodySmall + ) + TextButton(onClick = { showBgReliability.value = true }) { + Text(stringResource(R.string.bg_reliability_title)) } - }, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = openCd } - ) { - Text(text = stringResource(R.string.about_matrix_open_button)) + } } - HorizontalDivider() - Text( text = stringResource(R.string.about_copyright), style = MaterialTheme.typography.bodySmall, @@ -182,6 +817,223 @@ fun AboutDialog(onDismiss: () -> Unit) { ) } +/** + * v2.5.25 — readLicenseBytes() läuft vollständig auf Dispatchers.IO (Aufrufer-Pflicht). + * + * v2.5.24 — Robuste Lizenzdatei-Lesefunktion mit drei sequenziellen Strategien. + * + * Strategien (in dieser Reihenfolge): + * a) contentResolver.openInputStream(uri) → copyTo(ByteArrayOutputStream) + * Standard-Weg. Scheitert auf MIUI/Xiaomi, wenn der URI nach Rückgabe aus dem + * Picker keine gültige temporäre Leseberechtigung mehr hat. + * b) contentResolver.openFileDescriptor(uri, "r") → FileInputStream(fd) + * Alternativer Weg über ParcelFileDescriptor. Gibt auf manchen OEM-Systemen + * einen gültigen FD auch wenn openInputStream() null zurückgibt. + * c) uri.scheme == "file" → File(uri.path!!).inputStream() + * Fallback für datei-basierte URIs (file://-Schema). Relevant für Geräte, die + * echte Dateipfade statt Content-URIs liefern. + * + * WICHTIG: Diese Funktion MUSS auf einem IO-Thread aufgerufen werden + * (z.B. withContext(Dispatchers.IO) { readLicenseBytes(...) }). + * NetworkOnMainThreadException und StrictMode-Verstöße werden so verhindert. + * + * Rückgabe: Pair + * - Wenn Bytes gelesen: Pair(bytes, null) + * - Wenn alle Strategien scheitern: Pair(null, diagnoseMeldung) + */ +private fun readLicenseBytes(context: Context, uri: Uri): Pair { + val errors = mutableListOf() + + // Strategie a: openInputStream + try { + val stream = context.contentResolver.openInputStream(uri) + if (stream != null) { + val out = ByteArrayOutputStream() + stream.use { it.copyTo(out) } + val bytes = out.toByteArray() + if (bytes.isNotEmpty()) return Pair(bytes, null) + // Leeres Ergebnis ist kein Fehler der Strategie — wird vom Aufrufer geprüft. + return Pair(bytes, null) + } else { + errors.add("InputStream: null") + } + } catch (e: SecurityException) { + errors.add("InputStream: SecurityException (${e.message?.take(60) ?: "?"})") + } catch (e: IOException) { + errors.add("InputStream: IOException (${e.message?.take(60) ?: "?"})") + } catch (e: Throwable) { + errors.add("InputStream: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})") + } + + // Strategie b: openFileDescriptor + try { + val pfd: ParcelFileDescriptor? = context.contentResolver.openFileDescriptor(uri, "r") + if (pfd != null) { + pfd.use { descriptor -> + val out = ByteArrayOutputStream() + FileInputStream(descriptor.fileDescriptor).use { it.copyTo(out) } + val bytes = out.toByteArray() + return Pair(bytes, null) + } + } else { + errors.add("FileDescriptor: null") + } + } catch (e: SecurityException) { + errors.add("FileDescriptor: SecurityException (${e.message?.take(60) ?: "?"})") + } catch (e: IOException) { + errors.add("FileDescriptor: IOException (${e.message?.take(60) ?: "?"})") + } catch (e: Throwable) { + errors.add("FileDescriptor: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})") + } + + // Strategie c: file:-Schema + if (uri.scheme?.equals("file", ignoreCase = true) == true) { + try { + val path = uri.path + if (path != null) { + val file = File(path) + if (file.exists() && file.canRead()) { + val out = ByteArrayOutputStream() + file.inputStream().use { it.copyTo(out) } + return Pair(out.toByteArray(), null) + } else { + errors.add("File: nicht lesbar (existiert=${file.exists()})") + } + } else { + errors.add("File: path ist null") + } + } catch (e: Throwable) { + errors.add("File: ${e.javaClass.simpleName} (${e.message?.take(60) ?: "?"})") + } + } else { + errors.add("File: kein file:-Schema (scheme=${uri.scheme})") + } + + // Alle Strategien gescheitert + val diag = errors.joinToString("; ") + return Pair(null, diag) +} + +/** + * v2.5.25 — LicenseStatusProvider.import() läuft auf Dispatchers.IO (Aufrufer-Pflicht). + * + * v2.5.24 — Dialog zum manuellen Einfügen des Lizenztexts (JSON). + * + * Zweck: Fallback für Geräte/Dateimanager, die keine sauberen URIs liefern. + * Der Nutzer kann den Inhalt der .gps2audio-license-Datei (JSON-Text) direkt + * einfügen — entweder per Tippen oder per "Aus Zwischenablage". + * + * Wichtig: + * - Der eingefügte Text wird über LicenseStatusProvider.import() importiert. + * - Bestehende gültige Lizenz wird NICHT überschrieben, wenn der Text ungültig ist. + * - Der Dialog zeigt klar an, dass das eine Notlösung ist. + */ +@Composable +private fun LicenseTextImportDialog( + context: Context, + onDismiss: () -> Unit, + onImported: () -> Unit +) { + val scope = rememberCoroutineScope() + var text by remember { mutableStateOf("") } + var errorMsg by remember { mutableStateOf(null) } + var isImporting by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.about_license_paste_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.about_license_paste_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + ) + OutlinedTextField( + value = text, + onValueChange = { + text = it + errorMsg = null + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 200.dp), + label = { Text(stringResource(R.string.about_license_paste_field_label)) }, + placeholder = { Text("{ \"license_id\": \"...\", ... }") }, + maxLines = 12 + ) + // "Aus Zwischenablage" Button + TextButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) + as? ClipboardManager + val clip = clipboard?.primaryClip + if (clip != null && clip.itemCount > 0) { + val pasted = clip.getItemAt(0).coerceToText(context).toString() + text = pasted + errorMsg = null + } else { + errorMsg = context.getString(R.string.about_license_paste_clipboard_empty) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.about_license_paste_from_clipboard)) + } + errorMsg?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + errorMsg = context.getString(R.string.about_license_paste_empty_error) + return@Button + } + // v2.5.25 — JSON-Parsing + Signaturvalidierung auf Dispatchers.IO + isImporting = true + errorMsg = null + val bytes = trimmed.toByteArray(Charsets.UTF_8) + scope.launch { + val result = withContext(Dispatchers.IO) { + LicenseStatusProvider.import(context, bytes) + } + isImporting = false + if (result.success) { + Toast.makeText( + context, + context.getString(R.string.about_license_import_success, result.message), + Toast.LENGTH_LONG + ).show() + onImported() + } else { + errorMsg = context.getString( + R.string.about_license_import_fail_invalid, + result.message + ) + } + } + }, + enabled = text.isNotBlank() && !isImporting + ) { + Text(stringResource(R.string.about_license_paste_import_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @Composable private fun AboutRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -196,3 +1048,27 @@ private fun AboutRow(label: String, value: String) { ) } } + +@Composable +private fun AboutInlineRow( + label: String, + value: String, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "$label:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = color + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = color + ) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/BackgroundReliabilityDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/BackgroundReliabilityDialog.kt new file mode 100644 index 0000000..bf7698d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/BackgroundReliabilityDialog.kt @@ -0,0 +1,265 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.BackgroundReliabilityStore +import de.waypointaudio.util.AppVersion +import de.waypointaudio.util.BackgroundReliability +import kotlinx.coroutines.launch + +/** + * v2.5.13 — Dezenter Hinweis "Hintergrundwiedergabe absichern". + * + * Anzeigeregeln: + * - Nur sichtbar, wenn die App NICHT von Akkuoptimierungen ausgenommen ist + * (sonst gibt es nichts zu tun) UND der Nutzer den Hinweis für diese + * Version noch nicht weggeklickt hat. + * - Auf Xiaomi/Redmi/Poco wird die spezifische Schrittliste (Akku, Autostart, + * Recents-Schloss, Notifications) gezeigt — sonst eine generische Variante. + * - "Verstanden" merkt den aktuellen versionCode, "Nicht mehr anzeigen" ist + * eine Checkbox, weil so vorhandene Dialoge in der App (Versionshinweis, + * Terms) bereits funktionieren — konsistente UX. + * - Blockiert die Wiedergabe nicht: der Nutzer kann den Dialog jederzeit + * schließen, und das Playback läuft währenddessen weiter. + */ +@Composable +fun BackgroundReliabilityDialogHost( + /** + * Auslöser, ob der Hinweis überhaupt geprüft werden soll. Wird die + * Komponente immer in der Composition gehalten, übernimmt sie selbst + * die Anzeige-Entscheidung; das ist gewollt einfach. + */ + triggerCheck: Boolean, + onDismissed: () -> Unit = {} +) { + val context = LocalContext.current + val store = remember(context) { + BackgroundReliabilityStore(context.applicationContext) + } + val versionInfo = remember(context) { AppVersion.get(context) } + + val dismissedCode by store.dismissedVersionCode.collectAsState(initial = -1L) + val scope = rememberCoroutineScope() + + var visible by remember { mutableStateOf(false) } + var dontShowAgain by remember { mutableStateOf(false) } + + LaunchedEffect(triggerCheck, dismissedCode, versionInfo.versionCode) { + if (!triggerCheck) return@LaunchedEffect + if (dismissedCode < 0L) return@LaunchedEffect + if (dismissedCode >= versionInfo.versionCode) return@LaunchedEffect + // Bereits ausgenommen → kein Bedarf, Nutzer mit dem Hinweis zu nerven. + if (BackgroundReliability.isIgnoringBatteryOptimizations(context)) return@LaunchedEffect + visible = true + } + + if (!visible) return + + BackgroundReliabilityDialog( + isXiaomi = BackgroundReliability.isXiaomiFamily(), + isOptimizationsIgnored = BackgroundReliability.isIgnoringBatteryOptimizations(context), + dontShowAgain = dontShowAgain, + onDontShowAgainChanged = { dontShowAgain = it }, + onOpenBattery = { BackgroundReliability.openBatteryOptimizationSettings(context) }, + onOpenAppDetails = { BackgroundReliability.openAppDetailsSettings(context) }, + onOpenNotifications = { BackgroundReliability.openNotificationSettings(context) }, + onOpenXiaomiAutostart = { BackgroundReliability.openXiaomiAutostartSettings(context) }, + onDismiss = { + if (dontShowAgain) { + scope.launch { + store.setDismissedVersionCode(versionInfo.versionCode) + } + } + visible = false + onDismissed() + } + ) +} + +/** + * Manuell aufrufbarer Dialog für den Einstellungen/About-Bereich. Ignoriert + * den "für Version verstanden"-Speicher und zeigt den Hinweis immer. + */ +@Composable +fun BackgroundReliabilityManualDialog( + onClose: () -> Unit +) { + val context = LocalContext.current + BackgroundReliabilityDialog( + isXiaomi = BackgroundReliability.isXiaomiFamily(), + isOptimizationsIgnored = BackgroundReliability.isIgnoringBatteryOptimizations(context), + dontShowAgain = false, + onDontShowAgainChanged = {}, + showDontShowAgain = false, + onOpenBattery = { BackgroundReliability.openBatteryOptimizationSettings(context) }, + onOpenAppDetails = { BackgroundReliability.openAppDetailsSettings(context) }, + onOpenNotifications = { BackgroundReliability.openNotificationSettings(context) }, + onOpenXiaomiAutostart = { BackgroundReliability.openXiaomiAutostartSettings(context) }, + onDismiss = onClose + ) +} + +@Composable +private fun BackgroundReliabilityDialog( + isXiaomi: Boolean, + isOptimizationsIgnored: Boolean, + dontShowAgain: Boolean, + onDontShowAgainChanged: (Boolean) -> Unit, + onOpenBattery: () -> Unit, + onOpenAppDetails: () -> Unit, + onOpenNotifications: () -> Unit, + onOpenXiaomiAutostart: () -> Unit, + onDismiss: () -> Unit, + showDontShowAgain: Boolean = true +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.bg_reliability_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.bg_reliability_intro), + style = MaterialTheme.typography.bodyMedium + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Box(Modifier.padding(12.dp)) { + Text( + text = if (isOptimizationsIgnored) { + stringResource(R.string.bg_reliability_status_ok) + } else { + stringResource(R.string.bg_reliability_status_optimized) + }, + style = MaterialTheme.typography.bodySmall + ) + } + } + if (isXiaomi) { + Text( + text = stringResource(R.string.bg_reliability_xiaomi_heading), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_battery)) + BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_autostart)) + BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_lock)) + BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_notify)) + } else { + Text( + text = stringResource(R.string.bg_reliability_generic_heading), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + BulletLine(stringResource(R.string.bg_reliability_generic_item_battery)) + BulletLine(stringResource(R.string.bg_reliability_generic_item_notify)) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onOpenBattery, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.bg_reliability_btn_battery)) + } + OutlinedButton( + onClick = onOpenNotifications, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.bg_reliability_btn_notify)) + } + OutlinedButton( + onClick = onOpenAppDetails, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.bg_reliability_btn_app)) + } + if (isXiaomi) { + OutlinedButton( + onClick = onOpenXiaomiAutostart, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.bg_reliability_btn_autostart)) + } + } + } + + if (showDontShowAgain) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Checkbox( + checked = dontShowAgain, + onCheckedChange = onDontShowAgainChanged + ) + Text( + text = stringResource(R.string.bg_reliability_dont_show_again), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_reliability_understood)) + } + } + ) +} + +@Composable +private fun BulletLine(text: String) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = "• ", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt index c267e07..4871cb7 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt @@ -10,6 +10,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -20,6 +22,7 @@ import androidx.compose.ui.unit.dp import de.waypointaudio.R import de.waypointaudio.data.MusicSourceType import de.waypointaudio.data.PlaylistItem +import de.waypointaudio.data.StreamEntry import de.waypointaudio.data.TourAudioSettings import de.waypointaudio.data.WaypointMusicBehavior import de.waypointaudio.viewmodel.WaypointViewModel @@ -30,7 +33,7 @@ import de.waypointaudio.viewmodel.WaypointViewModel * Öffnet sich über das sichtbare Begleitmusik-Panel oder über das Overflow-Menü; * zeigt alle Optionen für die aktuell gewählte Tour. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun BegleitmusikDialog( tourName: String, @@ -40,19 +43,33 @@ fun BegleitmusikDialog( val context = LocalContext.current val initialSettings by viewModel.musicSettings.collectAsState() val musicPlaying by viewModel.musicPlaying.collectAsState() + val audioLibrary by viewModel.audioLibrary.collectAsState() + + // Sub-Dialog: Auswahl aus der Audio-Bibliothek + var showLibraryPicker by remember { mutableStateOf(false) } // Lokaler Bearbeitungszustand – erst beim Speichern in VM schreiben var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) } var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) } var localPlaylist by remember(initialSettings) { mutableStateOf(initialSettings.localPlaylist) } - var streamUrl by remember(initialSettings) { mutableStateOf(initialSettings.streamUrl ?: "") } + // Stream-Playlist – Legacy streamUrl wird beim Initialisieren in einen Eintrag migriert, + // sodass im Dialog immer die neue Repräsentation verwendet wird. + var streamPlaylist by remember(initialSettings) { + mutableStateOf(initialSettings.effectiveStreamPlaylist) + } var behavior by remember(initialSettings) { mutableStateOf(initialSettings.behavior) } var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) } var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) } var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) } var autoStartAfterWaypoint by remember(initialSettings) { mutableStateOf(initialSettings.autoStartAfterWaypoint) } + // Eingabezustand für „neuen Stream hinzufügen" + var newStreamName by remember(initialSettings) { mutableStateOf("") } + var newStreamUrl by remember(initialSettings) { mutableStateOf("") } + var newStreamError by remember { mutableStateOf("") } var streamUrlError by remember { mutableStateOf("") } + // Index des gerade editierten Stream-Eintrags (-1 = kein Edit-Modus) + var editingStreamIndex by remember { mutableStateOf(-1) } // Launcher für mehrere Audiodateien val multiPickLauncher = rememberLauncherForActivityResult( @@ -84,23 +101,19 @@ fun BegleitmusikDialog( fun validateAndSave(): Boolean { if (enabled && sourceType == MusicSourceType.STREAM_URL) { - val url = streamUrl.trim() - if (url.isBlank()) { - streamUrlError = context.getString(R.string.music_stream_url_empty_error) - return false - } - val lower = url.lowercase() - if (!lower.startsWith("http://") && !lower.startsWith("https://")) { - streamUrlError = context.getString(R.string.music_stream_url_invalid_error) + if (streamPlaylist.isEmpty()) { + streamUrlError = context.getString(R.string.music_stream_playlist_empty_error) return false } } streamUrlError = "" + // streamUrl-Legacy-Feld leer lassen – Playlist ist jetzt kanonisch. val settings = TourAudioSettings( enabled = enabled, sourceType = sourceType, localPlaylist = localPlaylist, - streamUrl = streamUrl.trim().ifBlank { null }, + streamPlaylist = streamPlaylist, + streamUrl = null, behavior = behavior, fadeDurationMs = fadeDurationMs, duckVolume = duckVolume, @@ -111,6 +124,18 @@ fun BegleitmusikDialog( return true } + fun validateStreamUrlInput(url: String): String? { + val trimmed = url.trim() + if (trimmed.isBlank()) { + return context.getString(R.string.music_stream_url_empty_error) + } + val lower = trimmed.lowercase() + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + return context.getString(R.string.music_stream_url_invalid_error) + } + return null + } + AlertDialog( onDismissRequest = onDismiss, title = { @@ -187,10 +212,16 @@ fun BegleitmusikDialog( 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( - stringResource(R.string.music_playlist_empty), + 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.5f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } else { localPlaylist.forEachIndexed { idx, item -> @@ -229,7 +260,11 @@ fun BegleitmusikDialog( } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { OutlinedButton( onClick = { multiPickLauncher.launch( @@ -241,6 +276,21 @@ fun BegleitmusikDialog( Spacer(Modifier.width(4.dp)) Text(stringResource(R.string.music_playlist_add)) } + // Aus Bibliothek auswählen (bereits bekannte Audiodateien + // wiederverwenden – tour-übergreifend & inkl. Wegpunkt-Audios) + if (audioLibrary.isNotEmpty()) { + OutlinedButton( + onClick = { showLibraryPicker = true } + ) { + Icon( + Icons.Filled.LibraryMusic, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.music_playlist_pick_from_library)) + } + } if (localPlaylist.isNotEmpty()) { OutlinedButton( onClick = { localPlaylist = emptyList() }, @@ -266,17 +316,165 @@ fun BegleitmusikDialog( } } - // ─── Stream-URL ─────────────────────────────────────────────── + // ─── Stream-Playlist ────────────────────────────────────────── if (sourceType == MusicSourceType.STREAM_URL) { + Text( + stringResource(R.string.music_stream_playlist_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (streamPlaylist.isEmpty()) { + Text( + stringResource(R.string.music_stream_playlist_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } else { + streamPlaylist.forEachIndexed { idx, entry -> + val displayLabel = entry.name.ifBlank { entry.url } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Filled.Radio, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayLabel, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (entry.name.isNotBlank() && entry.url.isNotBlank()) { + Text( + text = entry.url, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IconButton( + onClick = { + if (idx > 0) { + streamPlaylist = streamPlaylist.toMutableList().also { list -> + val tmp = list[idx - 1] + list[idx - 1] = list[idx] + list[idx] = tmp + } + } + }, + enabled = idx > 0, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.KeyboardArrowUp, + contentDescription = stringResource(R.string.music_stream_move_up), + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = { + if (idx < streamPlaylist.size - 1) { + streamPlaylist = streamPlaylist.toMutableList().also { list -> + val tmp = list[idx + 1] + list[idx + 1] = list[idx] + list[idx] = tmp + } + } + }, + enabled = idx < streamPlaylist.size - 1, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.music_stream_move_down), + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = { + editingStreamIndex = idx + newStreamName = entry.name + newStreamUrl = entry.url + newStreamError = "" + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(R.string.music_stream_edit), + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = { + streamPlaylist = streamPlaylist.toMutableList() + .also { it.removeAt(idx) } + if (editingStreamIndex == idx) { + editingStreamIndex = -1 + newStreamName = "" + newStreamUrl = "" + newStreamError = "" + } else if (editingStreamIndex > idx) { + editingStreamIndex -= 1 + } + }, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.music_stream_remove), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + // Anzeige des Stream-Playlist-Validierungsfehlers (z. B. leer beim Save) + if (streamUrlError.isNotBlank()) { + Text( + streamUrlError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + // ─── Eingabe: neuer / editierter Stream ─────────────────── + val editing = editingStreamIndex in streamPlaylist.indices + Text( + if (editing) stringResource(R.string.music_stream_edit_section_label) + else stringResource(R.string.music_stream_add_section_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) OutlinedTextField( - value = streamUrl, - onValueChange = { streamUrl = it; streamUrlError = "" }, + value = newStreamName, + onValueChange = { newStreamName = it }, + label = { Text(stringResource(R.string.music_stream_name_label)) }, + placeholder = { Text(stringResource(R.string.music_stream_name_placeholder)) }, + singleLine = true, + leadingIcon = { Icon(Icons.Filled.Label, null) }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = newStreamUrl, + onValueChange = { newStreamUrl = it; newStreamError = "" }, label = { Text(stringResource(R.string.music_stream_url_label)) }, placeholder = { Text("https://stream.example.com/radio") }, - isError = streamUrlError.isNotBlank(), + isError = newStreamError.isNotBlank(), supportingText = { - if (streamUrlError.isNotBlank()) { - Text(streamUrlError, color = MaterialTheme.colorScheme.error) + if (newStreamError.isNotBlank()) { + Text(newStreamError, color = MaterialTheme.colorScheme.error) } else { Text( stringResource(R.string.music_stream_url_hint), @@ -288,6 +486,89 @@ fun BegleitmusikDialog( singleLine = true, modifier = Modifier.fillMaxWidth() ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + val err = validateStreamUrlInput(newStreamUrl) + if (err != null) { + newStreamError = err + return@OutlinedButton + } + val entry = StreamEntry( + url = newStreamUrl.trim(), + name = newStreamName.trim() + ) + streamPlaylist = if (editing) { + streamPlaylist.toMutableList().also { list -> + list[editingStreamIndex] = entry + } + } else { + streamPlaylist + entry + } + editingStreamIndex = -1 + newStreamName = "" + newStreamUrl = "" + newStreamError = "" + streamUrlError = "" + } + ) { + Icon( + if (editing) Icons.Filled.Check else Icons.Filled.Add, + null, Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + if (editing) stringResource(R.string.music_stream_save_entry) + else stringResource(R.string.music_stream_add_entry) + ) + } + if (editing) { + OutlinedButton( + onClick = { + editingStreamIndex = -1 + newStreamName = "" + newStreamUrl = "" + newStreamError = "" + } + ) { + Text(stringResource(R.string.cancel)) + } + } + if (streamPlaylist.isNotEmpty()) { + OutlinedButton( + onClick = { + streamPlaylist = emptyList() + editingStreamIndex = -1 + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.music_stream_clear)) + } + } + } + + // Shuffle auch für Stream-Playlist erlauben (sinnvoll bei + // mehreren Sendern, die in zufälliger Reihenfolge laufen sollen) + if (streamPlaylist.size > 1) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.music_shuffle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = shuffle, onCheckedChange = { shuffle = it }) + } + } + // Hinweis zu YouTube/SoundCloud/radio.de Surface( color = MaterialTheme.colorScheme.secondaryContainer, @@ -456,6 +737,135 @@ fun BegleitmusikDialog( } } ) + + // Sub-Dialog: Bekannte Audiodateien aus der Bibliothek auswählen. + if (showLibraryPicker) { + LibraryPickerDialog( + library = audioLibrary, + alreadySelectedUris = localPlaylist.map { it.uriString }.toSet(), + onDismiss = { showLibraryPicker = false }, + onConfirm = { picked -> + if (picked.isNotEmpty()) { + // URI-Berechtigungen erneut sichern (idempotent für bereits + // freigegebene URIs; verhindert Lese-Fehler nach App-Restart) + picked.forEach { item -> + runCatching { + context.contentResolver.takePersistableUriPermission( + Uri.parse(item.uriString), + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + localPlaylist = (localPlaylist + picked).distinctBy { it.uriString } + } + showLibraryPicker = false + } + ) + } +} + +/** + * Sub-Dialog zur Auswahl von Audiodateien aus der tour-übergreifenden Audio-Bibliothek. + * + * Zeigt alle bekannten Audiodateien (aus Wegpunkten und allen Begleitmusik-Playlists), + * mit Häkchen für die Auswahl. Bereits in der aktuellen Playlist befindliche Dateien + * sind vorab angekreuzt und können hier nicht entfernt werden – nur hinzukommende + * Auswahl wird übergeben. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LibraryPickerDialog( + library: List, + alreadySelectedUris: Set, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit +) { + // Nur Dateien anzeigen, die noch nicht in der aktuellen Playlist sind + val available = remember(library, alreadySelectedUris) { + library.filter { it.uriString !in alreadySelectedUris } + } + val checkedUris = remember { mutableStateListOf() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.music_library_picker_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.music_library_picker_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (available.isEmpty()) { + Text( + stringResource(R.string.music_library_picker_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } else { + available.forEach { item -> + val isChecked = item.uriString in checkedUris + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { c -> + if (c) { + if (item.uriString !in checkedUris) checkedUris += item.uriString + } else { + checkedUris.remove(item.uriString) + } + } + ) + Icon( + Icons.Filled.AudioFile, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = checkedUris.isNotEmpty(), + onClick = { + val picked = available.filter { it.uriString in checkedUris } + onConfirm(picked) + } + ) { + Text(stringResource(R.string.music_library_picker_add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) } @Composable diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ClipDetailSheet.kt b/app/src/main/kotlin/de/waypointaudio/ui/ClipDetailSheet.kt new file mode 100644 index 0000000..32b8da5 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/ClipDetailSheet.kt @@ -0,0 +1,677 @@ +package de.waypointaudio.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.automirrored.filled.VolumeOff +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloseFullscreen +import androidx.compose.material.icons.filled.OpenInFull +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Stop +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import de.waypointaudio.data.PlaybackProgress +import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.formatRemaining +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sin + +/** + * v2.5.11 — Clip-Detailansicht (Bottom Sheet) für einen einzelnen + * Wegpunkt/Clip. + * + * Zeigt Titel + Tour-Kontext, Wiedergabe-Status, Play/Pause-Steuerung + * (Prev/Next falls sinnvoll), und eine animierte, gestaltete Waveform- + * /Spectrum-Visualisierung. Es findet *keine* echte FFT statt — die + * Visualisierung lebt rein von `infiniteRepeatable`, damit sie bei + * Wiedergabe lebendig wirkt und im Pause-Zustand zur Ruhe kommt. + * + * v2.5.15 — Ein zusätzlicher Maximize-Button erlaubt das Umschalten in + * eine echte Vollbild-Detailansicht ([ClipDetailFullscreen]), die mehr + * Raum für Waveform, Cliplist, Countdown und Transport-Steuerung bietet. + */ +data class WaypointDetailUiState( + val waypoint: Waypoint, + val tourName: String, + val positionInTour: Int, + val totalInTour: Int, + val isCurrentlyPlaying: Boolean, + /** + * Liste aller (aktiven und inaktiven) Clips dieses Waypoints. Wird + * ab v2.5.14 vollständig angezeigt; der gerade aktive Clip wird + * farblich hervorgehoben. + */ + val tracks: List, + /** + * v2.5.14 — Position des aktuell geladenen Clips innerhalb des Waypoints + * (1-basiert). 0 bedeutet "kein Clip geladen / unbekannt", in welchem + * Fall die Anzeige "Clip N/M" ausgeblendet wird. + */ + val currentClipIndex: Int = 0, + /** v2.5.14 — Anzahl Clips dieses Waypoints (Anzeige "Clip 1 von 3"). */ + val totalClips: Int = 0, + /** v2.5.14 — Fortschritts-/Countdown-Snapshot. */ + val progress: PlaybackProgress = PlaybackProgress.EMPTY, +) { + data class TrackPreview( + val title: String, + val uri: String, + val isPrimary: Boolean, + /** v2.5.14 — True, wenn dies der gerade geladene/abspielende Clip ist. */ + val isCurrent: Boolean = false, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClipDetailSheet( + state: WaypointDetailUiState, + onPlayPause: () -> Unit, + onPrevious: (() -> Unit)?, + onNext: (() -> Unit)?, + onStop: () -> Unit, + onDismiss: () -> Unit, + onMaximize: (() -> Unit)? = null, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + ClipDetailHeader( + state = state, + onDismiss = onDismiss, + onMaximize = onMaximize, + isFullscreen = false, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + ClipDetailBody( + state = state, + waveformHeight = 108.dp, + showTracksInline = true, + ) + ClipDetailTransport( + state = state, + onPlayPause = onPlayPause, + onPrevious = onPrevious, + onNext = onNext, + onStop = onStop, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +/** + * v2.5.15 — Vollbild-Variante der Clip-Detailansicht. Wird als modaler + * Dialog mit `usePlatformDefaultWidth = false` geöffnet, sodass die + * gesamte Bildschirmfläche zur Verfügung steht. Inhalte sind dieselben + * wie im Bottom-Sheet, aber luftiger angeordnet und mit größerer + * Waveform-Visualisierung. Die Clipliste wird scrollbar dargestellt, + * sodass auch Wegpunkte mit vielen Clips komfortabel bedient werden. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClipDetailFullscreen( + state: WaypointDetailUiState, + onPlayPause: () -> Unit, + onPrevious: (() -> Unit)?, + onNext: (() -> Unit)?, + onStop: () -> Unit, + onDismiss: () -> Unit, + onMinimize: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = state.waypoint.name.ifBlank { "Unbenannt" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onMinimize) { + Icon( + imageVector = Icons.Filled.CloseFullscreen, + contentDescription = "Vollbild verlassen", + ) + } + }, + actions = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Player schließen", + ) + } + } + ) + } + ) { innerPadding -> + ClipDetailFullscreenContent( + state = state, + onPlayPause = onPlayPause, + onPrevious = onPrevious, + onNext = onNext, + onStop = onStop, + contentPadding = innerPadding, + ) + } + } + } +} + +@Composable +private fun ClipDetailFullscreenContent( + state: WaypointDetailUiState, + onPlayPause: () -> Unit, + onPrevious: (() -> Unit)?, + onNext: (() -> Unit)?, + onStop: () -> Unit, + contentPadding: PaddingValues, +) { + val tracks = state.tracks + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Tour-/Positions-Kontext direkt unter dem TopAppBar-Titel. + val sub = buildString { + append("Tour: ${state.tourName}") + if (state.totalInTour > 0) { + append(" · ") + append("Position ${state.positionInTour}/${state.totalInTour}") + } + if (!state.waypoint.isActive) append(" · inaktiv") + } + Text( + text = sub, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + + // Größere Waveform für mehr Bühne im Vollbild. + ClipWaveform( + isPlaying = state.isCurrentlyPlaying, + hasAudio = tracks.any { it.uri.isNotBlank() } || + state.waypoint.soundUri.isNotBlank(), + heightDp = 200, + ) + + ClipDetailStatusRow(state = state) + ClipDetailRemainingRow(state = state) + + // Cliplist scrollt unabhängig, falls viele Clips vorhanden sind. + if (tracks.isEmpty()) { + ClipDetailEmptyTracksRow() + } else { + Text( + text = "Clips", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(tracks, key = { it.uri + "|" + it.title }) { track -> + ClipTrackRow(track = track, isPlaying = state.isCurrentlyPlaying) + } + } + } + + ClipDetailTransport( + state = state, + onPlayPause = onPlayPause, + onPrevious = onPrevious, + onNext = onNext, + onStop = onStop, + buttonScale = 1.25f, + ) + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun ClipDetailHeader( + state: WaypointDetailUiState, + onDismiss: () -> Unit, + onMaximize: (() -> Unit)?, + isFullscreen: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.waypoint.name.ifBlank { "Unbenannt" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val sub = buildString { + append("Tour: ${state.tourName}") + if (state.totalInTour > 0) { + append(" · ") + append("Position ${state.positionInTour}/${state.totalInTour}") + } + if (!state.waypoint.isActive) append(" · inaktiv") + } + Text( + text = sub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (onMaximize != null && !isFullscreen) { + IconButton(onClick = onMaximize) { + Icon( + imageVector = Icons.Filled.OpenInFull, + contentDescription = "Detail maximieren", + ) + } + } + IconButton(onClick = onDismiss) { + Icon( + Icons.Filled.Close, + contentDescription = "Detail schließen" + ) + } + } +} + +@Composable +private fun ClipDetailBody( + state: WaypointDetailUiState, + waveformHeight: androidx.compose.ui.unit.Dp, + showTracksInline: Boolean, +) { + ClipWaveform( + isPlaying = state.isCurrentlyPlaying, + hasAudio = state.tracks.any { it.uri.isNotBlank() } || + state.waypoint.soundUri.isNotBlank(), + heightDp = waveformHeight.value.toInt(), + ) + ClipDetailStatusRow(state = state) + ClipDetailRemainingRow(state = state) + if (showTracksInline) { + if (state.tracks.isEmpty()) { + ClipDetailEmptyTracksRow() + } else { + state.tracks.forEach { track -> + ClipTrackRow(track = track, isPlaying = state.isCurrentlyPlaying) + } + } + } +} + +@Composable +private fun ClipDetailStatusRow(state: WaypointDetailUiState) { + val statusText = when { + state.waypoint.soundUri.isBlank() && state.tracks.isEmpty() + -> "Kein Audio hinterlegt" + state.isCurrentlyPlaying -> "Wiedergabe läuft" + else -> "Bereit" + } + val clipPositionLabel = + if (state.totalClips > 1 && state.currentClipIndex in 1..state.totalClips) { + "Clip ${state.currentClipIndex} von ${state.totalClips}" + } else null + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = statusText, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (clipPositionLabel != null) { + Spacer(Modifier.size(8.dp)) + Text( + text = clipPositionLabel, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ClipDetailRemainingRow(state: WaypointDetailUiState) { + val remainingLabel: String? = when { + !state.isCurrentlyPlaying && state.progress.positionMs <= 0L -> null + state.progress.durationMs > 0L -> + "Verbleibend " + formatRemaining(state.progress.remainingMs) + else -> null + } + if (remainingLabel != null) { + Text( + text = remainingLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + ) + } +} + +@Composable +private fun ClipDetailEmptyTracksRow() { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.size(8.dp)) + Text( + text = "Dieser Wegpunkt hat aktuell keinen Audioclip.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +@Composable +private fun ClipTrackRow(track: WaypointDetailUiState.TrackPreview, isPlaying: Boolean) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val dotActive = isPlaying && track.isCurrent + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(50)) + .background( + if (dotActive) + MaterialTheme.colorScheme.primary + else if (track.isCurrent) + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + ) + Spacer(Modifier.size(8.dp)) + Text( + text = track.title.ifBlank { "Audio-Datei" }, + style = MaterialTheme.typography.bodyMedium, + color = if (track.isCurrent) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun ClipDetailTransport( + state: WaypointDetailUiState, + onPlayPause: () -> Unit, + onPrevious: (() -> Unit)?, + onNext: (() -> Unit)?, + onStop: () -> Unit, + buttonScale: Float = 1f, +) { + val playSize = (64f * buttonScale).dp + val playIconSize = (48f * buttonScale).dp + val sideIconSize = (34f * buttonScale).dp + val stopIconSize = (28f * buttonScale).dp + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val prevEnabled = onPrevious != null + IconButton( + onClick = { onPrevious?.invoke() }, + enabled = prevEnabled + ) { + Icon( + Icons.Filled.SkipPrevious, + contentDescription = "Vorheriger Clip", + modifier = Modifier.size(sideIconSize), + tint = if (prevEnabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + IconButton( + onClick = onPlayPause, + enabled = state.tracks.isNotEmpty() || + state.waypoint.soundUri.isNotBlank(), + modifier = Modifier.size(playSize) + ) { + Icon( + imageVector = if (state.isCurrentlyPlaying) + Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (state.isCurrentlyPlaying) + "Pause" else "Wiedergabe", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(playIconSize) + ) + } + val nextEnabled = onNext != null + IconButton( + onClick = { onNext?.invoke() }, + enabled = nextEnabled + ) { + Icon( + Icons.Filled.SkipNext, + contentDescription = "Nächster Clip", + modifier = Modifier.size(sideIconSize), + tint = if (nextEnabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + IconButton( + onClick = onStop, + enabled = state.isCurrentlyPlaying + ) { + Icon( + Icons.Filled.Stop, + contentDescription = "Stop", + modifier = Modifier.size(stopIconSize), + tint = if (state.isCurrentlyPlaying) + MaterialTheme.colorScheme.onSurface + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + } +} + +/** + * Stilisierte Waveform / Pseudo-Spectrum-Analyzer. + * + * Bewusst keine echte FFT — die Visualisierung soll bei laufender Wiedergabe + * lebendig wirken und bei Pause/ohne Audio zur Ruhe kommen. Wir kombinieren + * mehrere Sinus-Wellen mit unterschiedlichen Frequenzen und einen langsam + * driftenden Phasenwert, was eine glaubhafte, organische Bewegung erzeugt. + * + * v2.5.15 — Die Höhe ist parametrisierbar, damit dieselbe Visualisierung + * sowohl im kompakten Bottom-Sheet als auch im Vollbild eingesetzt werden + * kann. + */ +@Composable +private fun ClipWaveform( + isPlaying: Boolean, + hasAudio: Boolean, + heightDp: Int = 108, +) { + val transition = rememberInfiniteTransition(label = "clip-wave") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = (2f * PI).toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2400, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "clip-wave-phase" + ) + + val primary = MaterialTheme.colorScheme.primary + val onSurface = MaterialTheme.colorScheme.onSurface + val height = heightDp.dp + + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + MaterialTheme.colorScheme.surface + ) + ) + ) + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + val w = size.width + val h = size.height + val midY = h / 2f + + // Quelle: Spectrum-Balken (32 Stück) — leben nur, wenn isPlaying. + val barCount = 32 + val barSpacing = 3f + val barWidth = (w - (barCount - 1) * barSpacing) / barCount + val activity = if (!hasAudio) 0.10f else if (isPlaying) 1.0f else 0.18f + + for (i in 0 until barCount) { + val u = i.toFloat() / (barCount - 1).toFloat() + // gemischtes Pseudo-Spektrum + val s1 = sin(u * 6f + phase) * 0.55f + val s2 = sin(u * 13f + phase * 1.7f + 0.5f) * 0.30f + val s3 = sin(u * 21f - phase * 0.6f) * 0.15f + val raw = (s1 + s2 + s3) + val amp = (abs(raw) * activity + 0.06f).coerceIn(0.06f, 1.0f) + val barH = amp * (h * 0.86f) + val x = i * (barWidth + barSpacing) + val top = midY - barH / 2f + val color = if (isPlaying && hasAudio) { + Color( + red = primary.red, + green = primary.green, + blue = primary.blue, + alpha = (0.55f + 0.35f * amp).coerceIn(0f, 1f) + ) + } else { + Color( + red = onSurface.red, + green = onSurface.green, + blue = onSurface.blue, + alpha = (0.18f + 0.12f * amp).coerceIn(0f, 1f) + ) + } + drawRoundRect( + color = color, + topLeft = Offset(x, top), + size = androidx.compose.ui.geometry.Size(barWidth, barH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f) + ) + } + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt new file mode 100644 index 0000000..6e1bf4f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreen.kt @@ -0,0 +1,2115 @@ +package de.waypointaudio.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.os.Looper +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddLocation +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.GpsOff +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.TouchApp +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.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import de.waypointaudio.R +import de.waypointaudio.data.ExplorerSettingsStore +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.MapBaseStyle +import de.waypointaudio.data.MapProvider +import de.waypointaudio.data.MapProviderStore +import de.waypointaudio.data.MapStyleSettings +import de.waypointaudio.data.MapStyleStore +import de.waypointaudio.data.TrackDraft +import de.waypointaudio.data.Waypoint +import de.waypointaudio.util.WaypointAudioDuration +import de.waypointaudio.viewmodel.WaypointViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.ITileSource +import org.osmdroid.tileprovider.tilesource.XYTileSource +import org.osmdroid.util.MapTileIndex +import org.osmdroid.events.MapEventsReceiver +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.MapEventsOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon +import org.osmdroid.views.overlay.Polyline +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Explorer (v2.2.3) — zentraler Karten-Arbeitsbereich. + * + * Erweiterungen ggü. v2.2.2: + * - GPS-Aufzeichnung Start/Stopp direkt im Explorer (FAB-Aktion). + * - Anzeige des laufenden bzw. zuletzt aufgezeichneten Tracks als Polyline. + * - Bottom-Sheet „Aufgenommene Tracks (Drafts)" zum Aktivieren/Editor öffnen. + * - Bottom-Sheet „Storyboard / Routenplanung" als MVP für eine + * geordnete Wegpunkt-Zusammenstellung — keine externe Routing-Engine. + * - Persistente Nachführungs-Einstellung über [ExplorerSettingsStore]. + * - Manuelles Verschieben/Zoomen der Karte schaltet Follow bewusst ab und + * bleibt aus, bis der Nutzer es explizit wieder einschaltet. + * - Suche (Place-Search) im Explorer integriert. + */ +@SuppressLint("MissingPermission") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExplorerScreen( + viewModel: WaypointViewModel, + onNavigateBack: () -> Unit, + onOpenInEditor: () -> Unit, + onOpenDraft: (String) -> Unit = {}, + currentMode: MapMode? = null, + onModeChange: ((MapMode) -> Unit)? = null +) { + val context = LocalContext.current + val allWaypoints by viewModel.waypoints.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val tourList by viewModel.tourList.collectAsState() + val tourDefaults by viewModel.tourDefaults.collectAsState() + val isRecording by viewModel.isRecording.collectAsState() + val currentTrack by viewModel.currentTrack.collectAsState() + val persistedTrack by viewModel.persistedTrack.collectAsState() + val drafts by viewModel.trackDrafts.collectAsState() + val lastSavedDraftId by viewModel.lastSavedDraftId.collectAsState() + // v2.3.1 — Single-clip playback state for in-dialog play/pause. + val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState() + val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() + + val waypoints = allWaypoints.filter { + it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour + } + + val primaryColor = MaterialTheme.colorScheme.primary + val primaryArgb = primaryColor.toArgb() + val tertiaryArgb = MaterialTheme.colorScheme.tertiary.toArgb() + + LaunchedEffect(Unit) { + Configuration.getInstance().apply { + userAgentValue = context.packageName + osmdroidTileCache = File(context.cacheDir, "osmdroid") + } + } + + val providerStore = remember { MapProviderStore(context.applicationContext) } + 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.3.0 — MapLibre/Overlays: persistente Karten-Stil-Einstellungen. + val mapStyleStore = remember { MapStyleStore(context.applicationContext) } + val mapStyleSettings by mapStyleStore.settings.collectAsState( + initial = MapStyleSettings() + ) + val scope = rememberCoroutineScope() + + // Audio-Dauer-Cache je Wegpunkt (URI → Millisekunden). + // Wird im Hintergrund (Dispatchers.IO) gefüllt, um die Karte ruhig zu halten. + val audioDurationsMs = remember { mutableStateMapOf() } + LaunchedEffect(waypoints.map { it.soundUri }) { + for (wp in waypoints) { + val uri = wp.soundUri + if (uri.isBlank()) continue + if (audioDurationsMs.containsKey(uri)) continue + val d = withContext(Dispatchers.IO) { + WaypointAudioDuration.resolveDurationMs(context, uri) + } + audioDurationsMs[uri] = d + } + } + + var showRoutingInfo by remember { mutableStateOf(false) } + // v2.3.1 — Routing/POI MVP State. + var routeLine by remember { mutableStateOf>(emptyList()) } + var routeStatus by remember { mutableStateOf(null) } + var routeLoading by remember { mutableStateOf(false) } + val routingPoiCtx = LocalContext.current + + var mapView by remember { mutableStateOf(null) } + var myMarker by remember { mutableStateOf(null) } + var accuracyOverlay by remember { mutableStateOf(null) } + + var lastLat by remember { mutableStateOf(null) } + var lastLng by remember { mutableStateOf(null) } + var lastAccuracyM by remember { mutableStateOf(null) } + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) + } + + // 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) } + var followInitialized by remember { mutableStateOf(false) } + LaunchedEffect(persistedSettings.followLocation) { + if (!followInitialized) { + followLocation = persistedSettings.followLocation + followInitialized = true + } + } + + var actionMenuWaypoint by remember { mutableStateOf(null) } + + var editDialogWaypoint by remember { mutableStateOf(null) } + var editDialogPrefill by remember { mutableStateOf?>(null) } + var showEditDialog by remember { mutableStateOf(false) } + + var deleteConfirm by remember { mutableStateOf(null) } + + var tapMode by remember { mutableStateOf(TapMode.None) } + + val puckBitmap = remember { explorerLocationPuckBitmap(context) } + + var layerState by remember { + mutableStateOf(MapLayerState(followLocation = persistedSettings.followLocation)) + } + // Initial-Sync der Stil-Einstellungen (einmalig, danach trägt onStateChange). + var styleHydrated by remember { mutableStateOf(false) } + LaunchedEffect(mapStyleSettings) { + if (!styleHydrated) { + layerState = layerState.copy( + baseStyle = mapStyleSettings.baseStyle, + showAudioLength = mapStyleSettings.showAudioLength, + showAudioLines = mapStyleSettings.showAudioLines, + ) + styleHydrated = true + } + } + LaunchedEffect(followLocation) { + if (layerState.followLocation != followLocation) { + layerState = layerState.copy(followLocation = followLocation) + } + runCatching { settingsStore.setFollowLocation(followLocation) } + } + LaunchedEffect(layerState.followLocation) { + if (followLocation != layerState.followLocation) { + followLocation = layerState.followLocation + } + } + // v2.3.0 — Persistierung der neuen Stil-Einstellungen. + LaunchedEffect(layerState.baseStyle) { + if (styleHydrated && layerState.baseStyle != mapStyleSettings.baseStyle) { + runCatching { mapStyleStore.setBaseStyle(layerState.baseStyle) } + } + } + LaunchedEffect(layerState.showAudioLength) { + if (styleHydrated && layerState.showAudioLength != mapStyleSettings.showAudioLength) { + runCatching { mapStyleStore.setShowAudioLength(layerState.showAudioLength) } + } + } + LaunchedEffect(layerState.showAudioLines) { + if (styleHydrated && layerState.showAudioLines != mapStyleSettings.showAudioLines) { + runCatching { mapStyleStore.setShowAudioLines(layerState.showAudioLines) } + } + } + + var showSearchDialog by remember { mutableStateOf(false) } + var searchPin by remember { mutableStateOf(null) } + + var showDraftsSheet by remember { mutableStateOf(false) } + var showStoryboardSheet by remember { mutableStateOf(false) } + var showDraftPointMenu by remember { mutableStateOf(null) } + + var activeDraftId by remember { mutableStateOf(null) } + val activeDraft = activeDraftId?.let { id -> drafts.firstOrNull { it.id == id } } + + var selectedTrackIndex by remember { mutableStateOf(null) } + + LaunchedEffect(lastSavedDraftId) { + val id = lastSavedDraftId + if (id != null) { + activeDraftId = id + showDraftsSheet = true + viewModel.consumeLastSavedDraftId() + } + } + + // v2.3.0 — Tile-Quelle wird jetzt durch (baseStyle, MapProvider, customSatelliteUrl) gemeinsam + // bestimmt. SATELLITE überlagert den vom Nutzer gewählten 2D-Provider mit ESRI oder einer + // eigenen XYZ-URL. PERSPECTIVE bleibt visuell beim 2D-Provider, ergänzt aber Kamera-Neigung. + LaunchedEffect( + selectedProvider, + layerState.baseStyle, + mapStyleSettings.customSatelliteUrl, + mapStyleSettings.perspectiveTilt + ) { + val mv = mapView ?: return@LaunchedEffect + val effective = resolveExplorerTileSource( + baseStyle = layerState.baseStyle, + selectedProvider = selectedProvider, + customSatelliteUrl = mapStyleSettings.customSatelliteUrl + ) + runCatching { mv.setTileSource(effective) } + // Perspektive: osmdroid kann die View rotieren — wir nutzen den persistierten + // Tilt-Winkel als Pseudo-Pitch-Vorgeschmack. Ein echter MapLibre-Renderer + // ersetzt dies später durch CameraPosition.pitch. + val rotation = if (layerState.baseStyle == MapBaseStyle.PERSPECTIVE) { + -mapStyleSettings.perspectiveTilt.toFloat() + } else 0f + runCatching { mv.mapOrientation = rotation } + mv.invalidate() + } + + // v2.5.36 — Phantom-Guard (analog zu ExplorerScreenMapLibre): + // isNeutralStandard vor currentTrack.isNotEmpty() prüfen, damit der + // Manager-Track für die neutrale Standardtour nicht als Phantom erscheint. + val isNeutralStandard: Boolean = + selectedTour == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + val visibleTrack: List = when { + isRecording -> currentTrack + activeDraft != null -> activeDraft.points + isNeutralStandard -> emptyList() + currentTrack.isNotEmpty() -> currentTrack + else -> persistedTrack + } + val visibleMarkedIndexes: List = activeDraft?.markedIndexes ?: emptyList() + + // v2.3.1 — Berechnete Routenlinie als separater Overlay halten, damit der + // bestehende Overlay-Refresh sie nicht versehentlich entfernt. + LaunchedEffect(routeLine) { + val mv = mapView ?: return@LaunchedEffect + // alte Route-Polylines entfernen (Markierung über title=ROUTE_TAG) + val oldRoute = mv.overlays.filterIsInstance().filter { it.title == ROUTE_TAG } + mv.overlays.removeAll(oldRoute.toSet()) + if (routeLine.size >= 2) { + val halo = Polyline(mv).apply { + setPoints(routeLine) + outlinePaint.color = 0xFFFFFFFF.toInt() + outlinePaint.strokeWidth = 18f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 220 + infoWindow = null + title = ROUTE_TAG + } + val line = Polyline(mv).apply { + setPoints(routeLine) + outlinePaint.color = 0xFF1976D2.toInt() + outlinePaint.strokeWidth = 10f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 240 + infoWindow = null + title = ROUTE_TAG + } + mv.overlays.add(halo) + mv.overlays.add(line) + } + mv.invalidate() + } + + LaunchedEffect( + waypoints, + primaryArgb, + actionMenuWaypoint?.id, + visibleTrack, + visibleMarkedIndexes, + selectedTrackIndex, + searchPin, + layerState, + audioDurationsMs.toMap() + ) { + val mv = mapView ?: return@LaunchedEffect + drawExplorerOverlays( + mv = mv, + waypoints = if (layerState.showMarkers) waypoints else emptyList(), + track = if (layerState.showTrack) visibleTrack else emptyList(), + markedIndexes = visibleMarkedIndexes, + selectedTrackIndex = selectedTrackIndex, + primaryArgb = primaryArgb, + trackArgb = tertiaryArgb, + showRadius = layerState.showRadius, + showAudioLength = layerState.showAudioLength, + showAudioLines = layerState.showAudioLines, + audioDurationsMs = audioDurationsMs.toMap(), + selectedId = actionMenuWaypoint?.id, + searchPin = searchPin, + onWaypointTap = { wp -> actionMenuWaypoint = wp }, + // v2.5.33 — Phantom-Track-Fix: Trackpunkt-Tap nur verarbeiten, + // wenn ein gültiger aktiver Draft oder eine laufende Aufzeichnung + // existiert. Verwaiste Marker (kein Draft, kein Recording) werden + // still ignoriert. + onTrackPointTap = { idx -> + if (activeDraft != null || isRecording) { + showDraftPointMenu = DraftPointSelection(idx) + } + } + ) + } + + DisposableEffect(hasPermission) { + if (!hasPermission) return@DisposableEffect onDispose { } + val client = LocationServices.getFusedLocationProviderClient(context) + val req = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000L) + .setMinUpdateIntervalMillis(1_000L) + .build() + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + val loc = result.lastLocation ?: return + lastLat = loc.latitude + lastLng = loc.longitude + lastAccuracyM = if (loc.hasAccuracy()) loc.accuracy else null + val mv = mapView + if (mv != null) { + val gp = GeoPoint(loc.latitude, loc.longitude) + myMarker = updateExplorerMyLocationMarker(mv, gp, myMarker, puckBitmap) + accuracyOverlay = updateExplorerAccuracyCircle( + mv = mv, + center = gp, + radiusMeters = lastAccuracyM?.toDouble() ?: 0.0, + existing = accuracyOverlay, + colorArgb = primaryArgb + ) + if (followLocation) { + mv.controller.animateTo(gp) + } + mv.invalidate() + } + } + } + runCatching { + client.requestLocationUpdates(req, callback, Looper.getMainLooper()) + } + onDispose { + runCatching { client.removeLocationUpdates(callback) } + } + } + + // ─── Dialogs ───────────────────────────────────────────────────────────── + + actionMenuWaypoint?.let { wp -> + AlertDialog( + onDismissRequest = { actionMenuWaypoint = null }, + title = { + Text( + text = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }, + style = MaterialTheme.typography.titleMedium + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "%.6f, %.6f · %dm".format( + Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + val wpDuration = audioDurationsMs[wp.soundUri] ?: 0L + if (wpDuration > 0L) { + Text( + stringResource( + R.string.explorer_waypoint_audio_duration, + WaypointAudioDuration.formatShort(wpDuration) + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Button( + onClick = { + editDialogWaypoint = wp + editDialogPrefill = null + showEditDialog = true + actionMenuWaypoint = null + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_edit)) + } + Button( + onClick = { + tapMode = TapMode.MoveWaypoint(wp.id) + actionMenuWaypoint = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_tap)) + } + // v2.3.1 — „Clip abspielen" ersetzt den prominenten + // „Auf Kartenmitte verschieben"-Knopf. Nutzt die bestehende + // ManualAudioPlayer-Single-Mode-Logik (kein Auto-Weiter, + // PTT/Atmo-Pause-Regeln greifen über die ViewModel-Pfade). + val hasClip = wp.soundUri.isNotBlank() + val isThisPlaying = hasClip && + singlePlayingWaypointId == wp.id && singlePlayingActive + Button( + onClick = { + if (hasClip) viewModel.manualPlaySingle(wp) + }, + enabled = hasClip, + colors = ButtonDefaults.buttonColors( + containerColor = if (isThisPlaying) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.tertiaryContainer, + contentColor = if (isThisPlaying) + MaterialTheme.colorScheme.onError + else MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + if (isThisPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + if (!hasClip) + stringResource(R.string.explorer_action_no_clip) + else if (isThisPlaying) + stringResource(R.string.explorer_action_pause_clip) + else + stringResource(R.string.explorer_action_play_clip) + ) + } + Button( + onClick = { + deleteConfirm = wp + actionMenuWaypoint = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.delete)) + } + } + }, + confirmButton = { + TextButton(onClick = { actionMenuWaypoint = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + deleteConfirm?.let { wp -> + AlertDialog( + onDismissRequest = { deleteConfirm = null }, + title = { Text(stringResource(R.string.confirm_delete_waypoint_title)) }, + text = { + Text( + stringResource( + R.string.confirm_delete_waypoint_msg, + wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) } + ) + ) + }, + confirmButton = { + Button( + onClick = { + viewModel.delete(wp.id) + deleteConfirm = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { Text(stringResource(R.string.delete)) } + }, + dismissButton = { + TextButton(onClick = { deleteConfirm = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showEditDialog) { + WaypointEditDialog( + waypoint = editDialogWaypoint, + prefillLatLng = if (editDialogWaypoint == null) editDialogPrefill else null, + existingTours = tourList, + prefillTourName = selectedTour, + tourDefaults = tourDefaults, + onConfirm = { newWaypoint -> + viewModel.upsert(newWaypoint) + showEditDialog = false + editDialogWaypoint = null + editDialogPrefill = null + }, + onDismiss = { + showEditDialog = false + editDialogWaypoint = null + editDialogPrefill = null + }, + // v2.5.16 — Kopieren auch aus Explorer-Bearbeiten-Flow möglich. + onCopyToOtherTour = editDialogWaypoint?.let { wp -> + { targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) } + } + ) + } + + if (showRoutingInfo) { + RoutingPoiSheet( + centerLat = lastLat ?: mapView?.mapCenter?.latitude, + centerLon = lastLng ?: mapView?.mapCenter?.longitude, + routingEndpoint = mapStyleSettings.routingEndpoint, + onRoutingEndpointChange = { url -> + scope.launch { runCatching { mapStyleStore.setRoutingEndpoint(url) } } + }, + onAddPoiAsWaypoint = { poi -> + val newWp = Waypoint( + id = java.util.UUID.randomUUID().toString(), + name = poi.displayName.take(60), + latitude = poi.latitude, + longitude = poi.longitude, + radiusMeters = 30f, + soundUri = "", + tourName = selectedTour, + isActive = true + ) + viewModel.upsert(newWp) + }, + onAddPoiToStoryboard = { poi -> + val newWp = Waypoint( + id = java.util.UUID.randomUUID().toString(), + name = poi.displayName.take(60), + latitude = poi.latitude, + longitude = poi.longitude, + radiusMeters = 30f, + soundUri = "", + tourName = selectedTour, + isActive = true + ) + viewModel.upsert(newWp) + showStoryboardSheet = true + }, + onComputeRoute = { + val endpoint = mapStyleSettings.routingEndpoint.trim() + if (endpoint.isBlank()) { + routeStatus = routingPoiCtx.getString(R.string.routing_endpoint_not_configured) + return@RoutingPoiSheet + } + val pts = waypoints.map { it.latitude to it.longitude } + if (pts.size < 2) { + routeStatus = routingPoiCtx.getString(R.string.routing_compute_no_points) + return@RoutingPoiSheet + } + routeLoading = true + routeStatus = routingPoiCtx.getString(R.string.routing_compute_loading) + scope.launch { + runCatching { + de.waypointaudio.util.OsrmRoutingClient.route(routingPoiCtx, endpoint, pts) + }.onSuccess { result -> + routeLine = result.polyline.map { (lat, lon) -> GeoPoint(lat, lon) } + routeStatus = routingPoiCtx.getString( + R.string.routing_compute_success, + de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), + de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) + ) + }.onFailure { + routeLine = emptyList() + routeStatus = routingPoiCtx.getString(R.string.routing_compute_error) + } + routeLoading = false + } + }, + routeStatus = routeStatus, + onDismiss = { showRoutingInfo = false } + ) + } + + if (showSearchDialog) { + PlaceSearchDialog( + onDismiss = { showSearchDialog = false }, + onResultSelected = { result -> + val gp = GeoPoint(result.latitude, result.longitude) + searchPin = gp + mapView?.controller?.animateTo(gp) + mapView?.controller?.setZoom(15.0) + // Suche bringt die Karte an einen anderen Ort — Follow ausschalten. + followLocation = false + showSearchDialog = false + } + ) + } + + showDraftPointMenu?.let { sel -> + val idx = sel.index + val draft = activeDraft + val pt = if (draft != null) draft.points.getOrNull(idx) else visibleTrack.getOrNull(idx) + AlertDialog( + onDismissRequest = { showDraftPointMenu = null }, + title = { Text(stringResource(R.string.explorer_track_point_title, idx + 1)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (pt != null) { + Text( + "%.6f, %.6f".format(Locale.US, pt.latitude, pt.longitude), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + } + if (draft != null) { + val isMarked = idx in draft.markedIndexes + Button( + onClick = { + val newMarked = if (isMarked) draft.markedIndexes - idx + else (draft.markedIndexes + idx).distinct().sorted() + viewModel.saveDraft(draft.copy(markedIndexes = newMarked)) + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isMarked) MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Bookmarks, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + if (isMarked) stringResource(R.string.draft_point_unmark) + else stringResource(R.string.draft_point_mark) + ) + } + Button( + onClick = { + selectedTrackIndex = idx + tapMode = TapMode.MoveTrackPoint(idx) + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_tap)) + } + Button( + onClick = { + val mv = mapView + if (mv != null) { + val center = mv.mapCenter + val pts = draft.points.toMutableList() + if (idx in pts.indices) { + pts[idx] = pts[idx].copy( + latitude = center.latitude, + longitude = center.longitude + ) + viewModel.saveDraft(draft.copy(points = pts)) + } + } + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_to_center)) + } + Button( + onClick = { + val pts = draft.points.toMutableList() + if (idx in pts.indices) { + pts.removeAt(idx) + val newMarked = draft.markedIndexes + .filter { it != idx } + .map { if (it > idx) it - 1 else it } + viewModel.saveDraft(draft.copy(points = pts, markedIndexes = newMarked)) + } + showDraftPointMenu = null + }, + enabled = !isRecording, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.delete)) + } + } else { + Text( + stringResource(R.string.explorer_track_point_no_draft_hint), + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showDraftPointMenu = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // ─── Bottom-Sheets ─────────────────────────────────────────────────────── + + if (showDraftsSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showDraftsSheet = false }, + sheetState = sheetState + ) { + DraftsPanel( + drafts = drafts, + activeDraftId = activeDraftId, + isRecording = isRecording, + onActivate = { id -> + activeDraftId = if (activeDraftId == id) null else id + val mv = mapView + val first = drafts.firstOrNull { it.id == id }?.points?.firstOrNull() + if (mv != null && first != null) { + mv.controller.animateTo(GeoPoint(first.latitude, first.longitude)) + mv.controller.setZoom(15.0) + followLocation = false + } + }, + onOpenFullEditor = { id -> onOpenDraft(id) }, + onDelete = { id -> + viewModel.deleteDraft(id) + if (activeDraftId == id) activeDraftId = null + }, + onImport = { id -> + val d = drafts.firstOrNull { it.id == id } ?: return@DraftsPanel + viewModel.importDraftAsWaypoints(d, selectedTour) + }, + onClose = { showDraftsSheet = false } + ) + } + } + + if (showStoryboardSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showStoryboardSheet = false }, + sheetState = sheetState + ) { + StoryboardPanel( + tourWaypoints = waypoints, + currentTour = selectedTour, + onSaveAsTour = { targetTour, orderedIds -> + viewModel.applyStoryboardOrder(targetTour, orderedIds) + showStoryboardSheet = false + }, + onClose = { showStoryboardSheet = false } + ) + } + } + + // ─── Scaffold ──────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + TopAppBar( + title = { + if (currentMode != null && onModeChange != null) { + MapModeSegmentedHeader( + current = currentMode, + onChange = onModeChange, + subtitle = selectedTour + ) + } else { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Explore, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_title)) + } + Text( + text = selectedTour, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + actions = { + IconButton(onClick = { showSearchDialog = true }) { + Icon( + Icons.Filled.Search, + contentDescription = stringResource(R.string.search_place_action) + ) + } + IconButton(onClick = { followLocation = !followLocation }) { + Icon( + if (followLocation) Icons.Filled.GpsFixed else Icons.Filled.GpsOff, + contentDescription = if (followLocation) + stringResource(R.string.explorer_follow_on) + else + stringResource(R.string.explorer_follow_off) + ) + } + IconButton(onClick = { + tapMode = if (tapMode is TapMode.PlaceNew) TapMode.None else TapMode.PlaceNew + }) { + Icon( + Icons.Filled.TouchApp, + contentDescription = stringResource(R.string.explorer_action_tap_place), + tint = if (tapMode is TapMode.PlaceNew) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.onPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + floatingActionButton = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SmallFloatingActionButton( + onClick = { showDraftsSheet = true }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Filled.Timeline, contentDescription = stringResource(R.string.explorer_drafts_open)) + } + SmallFloatingActionButton( + onClick = { showStoryboardSheet = true }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Filled.MovieFilter, contentDescription = stringResource(R.string.explorer_storyboard_open)) + } + + // v2.3.1 — Track-Aufnahme FAB: im Ruhezustand icon-only, + // während Aufnahme bleibt Extended mit Status-Text sichtbar. + if (isRecording) { + ExtendedFloatingActionButton( + onClick = { viewModel.stopTrackRecording() }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + icon = { Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.map_track_stop)) }, + text = { Text(stringResource(R.string.map_track_recording)) } + ) + } else { + FloatingActionButton( + onClick = { viewModel.startTrackRecording() }, + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) { + Icon( + Icons.Filled.FiberManualRecord, + contentDescription = stringResource(R.string.map_track_start) + ) + } + } + + // v2.3.1 — Wegpunkt-FAB icon-only, contentDescription bleibt + // für TalkBack/Tooltip erhalten. + FloatingActionButton( + onClick = { + val mv = mapView + val center = mv?.mapCenter + val pre = if (center != null) Pair(center.latitude, center.longitude) + else if (lastLat != null && lastLng != null) Pair(lastLat!!, lastLng!!) + else null + editDialogWaypoint = null + editDialogPrefill = pre + showEditDialog = true + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon( + Icons.Filled.AddLocation, + contentDescription = stringResource(R.string.explorer_add_waypoint) + ) + } + + FloatingActionButton( + onClick = { + val mv = mapView ?: return@FloatingActionButton + val lat = lastLat + val lng = lastLng + if (lat != null && lng != null) { + mv.controller.animateTo(GeoPoint(lat, lng)) + mv.controller.setZoom(17.0) + followLocation = true + } else if (waypoints.isNotEmpty()) { + val first = waypoints.first() + mv.controller.animateTo(GeoPoint(first.latitude, first.longitude)) + mv.controller.setZoom(15.0) + } + }, + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + Icons.Filled.MyLocation, + contentDescription = stringResource(R.string.explorer_my_location), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + ) { inner -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(inner) + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + MapView(ctx).apply { + setTileSource(selectedProvider.toTileSource()) + setMultiTouchControls(true) + controller.setZoom(13.0) + + val center = if (waypoints.isNotEmpty()) { + GeoPoint(waypoints.first().latitude, waypoints.first().longitude) + } else { + GeoPoint(51.1657, 10.4515) + } + controller.setCenter(center) + + val eventsReceiver = object : MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean { + if (p == null) return false + when (val m = tapMode) { + is TapMode.PlaceNew -> { + editDialogWaypoint = null + editDialogPrefill = Pair(p.latitude, p.longitude) + showEditDialog = true + tapMode = TapMode.None + return true + } + is TapMode.MoveWaypoint -> { + val target = allWaypoints.firstOrNull { it.id == m.id } + if (target != null) { + viewModel.upsert( + target.copy(latitude = p.latitude, longitude = p.longitude) + ) + } + tapMode = TapMode.None + return true + } + is TapMode.MoveTrackPoint -> { + val draft = activeDraft + if (draft != null) { + val pts = draft.points.toMutableList() + if (m.index in pts.indices) { + pts[m.index] = pts[m.index].copy( + latitude = p.latitude, + longitude = p.longitude + ) + viewModel.saveDraft(draft.copy(points = pts)) + } + } + tapMode = TapMode.None + selectedTrackIndex = null + return true + } + TapMode.None -> return false + } + } + override fun longPressHelper(p: GeoPoint?): Boolean { + if (p == null) return false + editDialogWaypoint = null + editDialogPrefill = Pair(p.latitude, p.longitude) + showEditDialog = true + return true + } + } + overlays.add(MapEventsOverlay(eventsReceiver)) + + // Map-Listener: bei Nutzer-Geste (Scroll/Zoom) Follow deaktivieren, + // sodass die Nachführung nicht automatisch wieder anspringt. + addMapListener(object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean { + if (followLocation) { + val lat = lastLat; val lng = lastLng + if (lat != null && lng != null) { + val mc = mapCenter + val dLat = Math.abs(mc.latitude - lat) + val dLng = Math.abs(mc.longitude - lng) + if (dLat > 0.00040 || dLng > 0.00040) { + followLocation = false + } + } + } + return false + } + override fun onZoom(event: ZoomEvent?): Boolean = false + }) + + mapView = this + drawExplorerOverlays( + mv = this, + waypoints = waypoints, + track = if (layerState.showTrack) visibleTrack else emptyList(), + markedIndexes = visibleMarkedIndexes, + selectedTrackIndex = null, + primaryArgb = primaryArgb, + trackArgb = tertiaryArgb, + showRadius = layerState.showRadius, + showAudioLength = layerState.showAudioLength, + showAudioLines = layerState.showAudioLines, + audioDurationsMs = audioDurationsMs.toMap(), + selectedId = null, + searchPin = searchPin, + onWaypointTap = { wp -> actionMenuWaypoint = wp }, + // v2.5.33 — Phantom-Track-Fix (zweite Render-Instanz): + // identische Schutzlogik wie oben. + onTrackPointTap = { idx -> + if (activeDraft != null || isRecording) { + showDraftPointMenu = DraftPointSelection(idx) + } + } + ) + } + }, + update = { mv -> + mapView = mv + } + ) + + // v2.3.1 — Kompakte Info-Chips ersetzen die textlastige Infobox. + // In Tap-Modi wird die Aktionsanleitung weiterhin als kurzer Hinweis + // gezeigt, sonst nur ein knapper Status (REC / Draft / Karte) und + // optional die GPS-Genauigkeit als Chip. + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), + shadowElevation = 4.dp, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp, start = 12.dp, end = 12.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val tapHint: String? = when (tapMode) { + is TapMode.PlaceNew -> stringResource(R.string.explorer_tap_place_active) + is TapMode.MoveWaypoint -> stringResource(R.string.explorer_tap_move_active) + is TapMode.MoveTrackPoint -> stringResource(R.string.explorer_tap_move_track_active) + TapMode.None -> null + } + if (tapHint != null) { + Icon( + Icons.Filled.TouchApp, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + tapHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val statusIcon = when { + isRecording -> Icons.Filled.FiberManualRecord + activeDraft != null -> Icons.Filled.Timeline + else -> Icons.Filled.Place + } + val statusText = when { + isRecording -> stringResource( + R.string.explorer_status_recording_compact, currentTrack.size + ) + activeDraft != null -> stringResource( + R.string.explorer_status_draft_compact, + activeDraft.markedCount, activeDraft.pointCount + ) + else -> stringResource( + R.string.explorer_status_compact, waypoints.size + ) + } + val statusTint = when { + isRecording -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + Icon( + statusIcon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = statusTint + ) + Text( + statusText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val acc = lastAccuracyM + if (acc != null) { + Text( + "· ${stringResource(R.string.explorer_accuracy_meters, acc.toInt())}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } + } + } + } + + AnimatedVisibility( + visible = tapMode !is TapMode.None, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 40.dp), + enter = fadeIn() + slideInVertically(initialOffsetY = { it }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it }) + ) { + FilterChip( + selected = true, + onClick = { tapMode = TapMode.None }, + label = { + Text(stringResource(R.string.explorer_cancel_tap)) + } + ) + } + + MapLayerFab( + state = layerState, + onStateChange = { layerState = it }, + customSatelliteUrl = mapStyleSettings.customSatelliteUrl, + onCustomSatelliteUrlChange = { url -> + scope.launch { + runCatching { mapStyleStore.setCustomSatelliteUrl(url) } + } + }, + onShowRoutingInfo = { showRoutingInfo = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + ) + + OsmAttributionBadge( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 6.dp) + ) + } + } + + DisposableEffect(Unit) { + onDispose { + mapView?.onDetach() + } + } +} + +private data class DraftPointSelection(val index: Int) + +private sealed interface TapMode { + data object None : TapMode + data object PlaceNew : TapMode + data class MoveWaypoint(val id: String) : TapMode + data class MoveTrackPoint(val index: Int) : TapMode +} + +// ─────────────────────────────────────────────────────────────────────────── +// Bottom-Sheet: Drafts-Verwaltung (im Explorer eingebettet). +// ─────────────────────────────────────────────────────────────────────────── + +@Composable +internal fun DraftsPanel( + drafts: List, + activeDraftId: String?, + isRecording: Boolean, + onActivate: (String) -> Unit, + onOpenFullEditor: (String) -> Unit, + onDelete: (String) -> Unit, + onImport: (String) -> Unit, + onClose: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Timeline, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.explorer_drafts_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(stringResource(R.string.cancel)) + } + } + Text( + stringResource(R.string.explorer_drafts_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(8.dp)) + if (drafts.isEmpty()) { + Text( + stringResource(R.string.draft_list_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + if (isRecording) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Text( + stringResource(R.string.draft_locked_while_recording), + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(drafts, key = { it.id }) { draft -> + val isActive = draft.id == activeDraftId + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onActivate(draft.id) }, + colors = CardDefaults.cardColors( + containerColor = if (isActive) + MaterialTheme.colorScheme.tertiaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + draft.name.ifBlank { stringResource(R.string.draft_unnamed) }, + style = MaterialTheme.typography.titleSmall + ) + val dateStr = SimpleDateFormat( + "dd.MM.yyyy HH:mm", Locale.getDefault() + ).format(Date(draft.updatedAt)) + Text( + stringResource( + R.string.draft_list_subtitle, + draft.sourceTourName, + dateStr + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + stringResource( + R.string.draft_list_points_dist, + draft.pointCount, + formatDistanceMeters(draft.points) + ), + style = MaterialTheme.typography.labelSmall + ) + } + IconButton(onClick = { onOpenFullEditor(draft.id) }) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(R.string.map_waypoint_edit) + ) + } + IconButton(onClick = { onImport(draft.id) }, enabled = !isRecording) { + Icon( + Icons.Filled.Bookmarks, + contentDescription = stringResource(R.string.draft_import_button) + ) + } + IconButton(onClick = { onDelete(draft.id) }, enabled = !isRecording) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Bottom-Sheet: Storyboard / Routenplanung (MVP). +// ─────────────────────────────────────────────────────────────────────────── + +@Composable +internal fun StoryboardPanel( + tourWaypoints: List, + currentTour: String, + onSaveAsTour: (targetTour: String, orderedIds: List) -> Unit, + onClose: () -> Unit +) { + val initialOrder = remember(tourWaypoints) { tourWaypoints.map { it.id } } + var order by remember(tourWaypoints) { mutableStateOf(initialOrder) } + var targetTourField by remember { mutableStateOf(currentTour) } + + val byId = remember(tourWaypoints) { tourWaypoints.associateBy { it.id } } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.MovieFilter, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.explorer_storyboard_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClose) { + Text(stringResource(R.string.cancel)) + } + } + Text( + stringResource(R.string.explorer_storyboard_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = targetTourField, + onValueChange = { targetTourField = it }, + label = { Text(stringResource(R.string.explorer_storyboard_target_tour)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + if (order.isEmpty()) { + Text( + stringResource(R.string.explorer_storyboard_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + LazyColumn( + modifier = Modifier.heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + itemsIndexed(order, key = { _, id -> id }) { idx, id -> + val wp = byId[id] ?: return@itemsIndexed + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${idx + 1}.", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.width(28.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }, + style = MaterialTheme.typography.bodyMedium + ) + Text( + "%.5f, %.5f".format(Locale.US, wp.latitude, wp.longitude), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + IconButton( + onClick = { + if (idx > 0) { + val m = order.toMutableList() + val tmp = m[idx - 1]; m[idx - 1] = m[idx]; m[idx] = tmp + order = m + } + }, + enabled = idx > 0 + ) { + Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.explorer_storyboard_up)) + } + IconButton( + onClick = { + if (idx < order.size - 1) { + val m = order.toMutableList() + val tmp = m[idx + 1]; m[idx + 1] = m[idx]; m[idx] = tmp + order = m + } + }, + enabled = idx < order.size - 1 + ) { + Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.explorer_storyboard_down)) + } + IconButton(onClick = { + order = order.toMutableList().also { it.removeAt(idx) } + }) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + val target = targetTourField.ifBlank { currentTour } + onSaveAsTour(target, order) + }, + enabled = order.isNotEmpty(), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_storyboard_save)) + } + } + 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) + ) + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Hilfsfunktionen für Karten-Overlays +// ─────────────────────────────────────────────────────────────────────────── + +private fun explorerLocationPuckBitmap(context: android.content.Context): Bitmap? { + return runCatching { + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck) + ?: return@runCatching null + val size = (48 * context.resources.displayMetrics.density).toInt() + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + }.getOrNull() +} + +private fun updateExplorerMyLocationMarker( + mapView: MapView, + position: GeoPoint, + existing: Marker?, + puckBitmap: Bitmap? +): Marker { + if (existing != null) { + mapView.overlays.remove(existing) + } + val marker = Marker(mapView).apply { + this.position = position + title = "Mein Standort" + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + if (puckBitmap != null) { + icon = android.graphics.drawable.BitmapDrawable(mapView.context.resources, puckBitmap) + } + infoWindow = null + } + mapView.overlays.add(marker) + return marker +} + +private fun updateExplorerAccuracyCircle( + mv: MapView, + center: GeoPoint, + radiusMeters: Double, + existing: Polygon?, + colorArgb: Int +): Polygon? { + if (existing != null) { + mv.overlays.remove(existing) + } + if (radiusMeters <= 0.0) return null + val points = mutableListOf() + val steps = 60 + val earthRadius = 6_371_000.0 + val lat0 = Math.toRadians(center.latitude) + val lon0 = Math.toRadians(center.longitude) + val d = radiusMeters / earthRadius + for (i in 0 until steps) { + val bearing = Math.toRadians(i * 360.0 / steps) + val lat1 = Math.asin( + Math.sin(lat0) * Math.cos(d) + Math.cos(lat0) * Math.sin(d) * Math.cos(bearing) + ) + val lon1 = lon0 + Math.atan2( + Math.sin(bearing) * Math.sin(d) * Math.cos(lat0), + Math.cos(d) - Math.sin(lat0) * Math.sin(lat1) + ) + points.add(GeoPoint(Math.toDegrees(lat1), Math.toDegrees(lon1))) + } + points.add(points.first()) + val polygon = Polygon().apply { + this.points = points + val alpha20 = (colorArgb and 0x00FFFFFF) or 0x33000000 + fillColor = alpha20 + strokeColor = colorArgb + strokeWidth = 2f + isVisible = true + title = ACCURACY_TAG + } + mv.overlays.add(polygon) + return polygon +} + +private const val ACCURACY_TAG = "explorer-accuracy" +private const val SEARCH_PIN_TAG = "explorer-search-pin" +private const val ROUTE_TAG = "explorer-route" + +/** + * v2.3.0 — Effektive Tile-Quelle aus Basis-Stil + Provider + Custom-URL. + * + * - MODERN_2D / PERSPECTIVE → osmdroid-Provider (vom Nutzer in v2.0.x gewählt). + * - SATELLITE → ESRI World Imagery (oder eigene XYZ-URL, falls gesetzt). + * + * Hinweis: PERSPECTIVE rendert weiterhin 2D-Raster; die „3D"-Wirkung entsteht aus + * der MapView-Rotation (Vorschau). Echtes 3D folgt mit MapLibre-Native. + */ +private fun resolveExplorerTileSource( + baseStyle: MapBaseStyle, + selectedProvider: MapProvider, + customSatelliteUrl: String +): ITileSource { + if (baseStyle == MapBaseStyle.SATELLITE) { + val custom = customSatelliteUrl.trim() + if (custom.isNotEmpty()) { + return CustomXYZTileSource(custom) + } + return MapProvider.ESRI_SATELLITE.toTileSource() + } + return selectedProvider.toTileSource() +} + +/** + * Generischer XYZ-Raster-Tile-Anbieter (z/x/y), zugeschnitten auf eigene + * Satelliten-Quellen. Akzeptierte URL-Schemas: + * https://host/path/{z}/{x}/{y}.png + * https://host/path/{z}/{x}/{y}.jpg + * Wenn keine Platzhalter vorhanden sind, wird die URL als Pfad-Präfix + * behandelt (klassischer osmdroid-Stil). + */ +private class CustomXYZTileSource( + template: String +) : XYTileSource( + "CustomSatellite", + 1, 19, 256, + if (template.contains("{z}")) "" else ".jpg", + arrayOf(if (template.contains("{z}")) template else template.trimEnd('/') + "/"), + "Satellit: eigene Tile-Quelle" +) { + private val tpl: String = template + override fun getTileURLString(pMapTileIndex: Long): String { + val z = MapTileIndex.getZoom(pMapTileIndex) + val x = MapTileIndex.getX(pMapTileIndex) + val y = MapTileIndex.getY(pMapTileIndex) + return if (tpl.contains("{z}")) { + tpl.replace("{z}", z.toString()) + .replace("{x}", x.toString()) + .replace("{y}", y.toString()) + } else { + tpl.trimEnd('/') + "/$z/$x/$y" + imageFilenameEnding() + } + } +} + +private fun drawExplorerOverlays( + mv: MapView, + waypoints: List, + track: List, + markedIndexes: List, + selectedTrackIndex: Int?, + primaryArgb: Int, + trackArgb: Int, + showRadius: Boolean, + showAudioLength: Boolean, + showAudioLines: Boolean = false, + audioDurationsMs: Map, + selectedId: String?, + searchPin: GeoPoint?, + onWaypointTap: (Waypoint) -> Unit, + onTrackPointTap: (Int) -> Unit +) { + val ownMarkers = mv.overlays.filterIsInstance().filter { it.title != "Mein Standort" } + mv.overlays.removeAll(ownMarkers.toSet()) + val ownPolys = mv.overlays.filterIsInstance().filter { it.title != ACCURACY_TAG } + mv.overlays.removeAll(ownPolys.toSet()) + // v2.3.1 — Berechnete Routenlinie (title=ROUTE_TAG) NICHT entfernen; sie wird + // in einem eigenen LaunchedEffect synchron mit dem `routeLine`-State gehalten. + val ownPolylines = mv.overlays.filterIsInstance().filter { it.title != ROUTE_TAG } + mv.overlays.removeAll(ownPolylines.toSet()) + + val density = mv.resources.displayMetrics.density + + if (track.size >= 2) { + val pts = track.map { GeoPoint(it.latitude, it.longitude) } + val halo = Polyline(mv).apply { + setPoints(pts) + outlinePaint.color = 0xFFFFFFFF.toInt() + outlinePaint.strokeWidth = 22f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 220 + infoWindow = null + } + val line = Polyline(mv).apply { + setPoints(pts) + outlinePaint.color = trackArgb + outlinePaint.strokeWidth = 14f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 255 + infoWindow = null + } + mv.overlays.add(halo) + mv.overlays.add(line) + } + + if (track.isNotEmpty()) { + val markedSet = markedIndexes.toSet() + track.forEachIndexed { idx, p -> + val gp = GeoPoint(p.latitude, p.longitude) + val isMarked = idx in markedSet + val isSelected = idx == selectedTrackIndex + val sizePx = when { + isSelected -> 80 + isMarked -> 64 + else -> 44 + } + val color = when { + isSelected -> 0xFFFF5252.toInt() + isMarked -> 0xFFFFAB00.toInt() + else -> trackArgb + } + val bm = explorerTrackPointBitmap(sizePx, color, isSelected) + val marker = Marker(mv).apply { + position = gp + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, bm) + title = null + infoWindow = null + setOnMarkerClickListener { _, _ -> + onTrackPointTap(idx) + true + } + } + mv.overlays.add(marker) + } + } + + // v2.4.0 — Clip-Längenlinien werden im osmdroid-Fallback nicht mehr frei + // projiziert. Die MapLibre-Variante (siehe ExplorerScreenMapLibre.kt) zeichnet + // Clipabschnitte ausschließlich entlang einer bekannten Geometrie + // (aufgezeichneter Track/Draft oder berechnete OSRM-Route). Falls dieser + // Fallback je aktiv werden sollte (interner Diagnose-Pfad), verzichten wir + // bewusst auf jede Fantasielinie, um die ehrliche Linien-Semantik nicht zu + // verletzen. Trigger-Radius (showRadius) und Audio-Längen-Badge am Marker + // (showAudioLength) bleiben weiterhin sichtbar. + @Suppress("UNUSED_PARAMETER") + val explorerOsmdroidAudioLinesDisabled = showAudioLines && waypoints.isNotEmpty() + + for (wp in waypoints) { + val gp = GeoPoint(wp.latitude, wp.longitude) + if (showRadius && wp.radiusMeters > 0) { + val points = mutableListOf() + val steps = 48 + val earthRadius = 6_371_000.0 + val lat0 = Math.toRadians(gp.latitude) + val lon0 = Math.toRadians(gp.longitude) + val d = wp.radiusMeters.toDouble() / earthRadius + for (i in 0 until steps) { + val bearing = Math.toRadians(i * 360.0 / steps) + val lat1 = Math.asin( + Math.sin(lat0) * Math.cos(d) + Math.cos(lat0) * Math.sin(d) * Math.cos(bearing) + ) + val lon1 = lon0 + Math.atan2( + Math.sin(bearing) * Math.sin(d) * Math.cos(lat0), + Math.cos(d) - Math.sin(lat0) * Math.sin(lat1) + ) + points.add(GeoPoint(Math.toDegrees(lat1), Math.toDegrees(lon1))) + } + points.add(points.first()) + val circle = Polygon().apply { + this.points = points + val alpha25 = (primaryArgb and 0x00FFFFFF) or 0x40000000 + fillColor = alpha25 + strokeColor = primaryArgb + strokeWidth = 1.5f + isVisible = true + } + mv.overlays.add(circle) + } + + val isSelected = wp.id == selectedId + // v2.3.0 — Audio-Längen-Indikator: Wenn Audiodauer bekannt und Layer aktiv, + // wird der Marker mit einem äußeren Fortschritts-Ring gezeichnet + // (Bucket-Farbe = kurz/mittel/lang). Ohne Dauer fällt der Marker auf + // den klassischen Punkt zurück; der Trigger-Radius bleibt sichtbar. + val durationMs = if (showAudioLength) { + audioDurationsMs[wp.soundUri] ?: 0L + } else 0L + val bm = if (durationMs > 0L) { + explorerWaypointDotWithDurationRing( + mv.context, primaryArgb, density, isSelected, durationMs + ) + } else { + explorerWaypointDotBitmap(mv.context, primaryArgb, density, isSelected) + } + val marker = Marker(mv).apply { + position = gp + title = wp.name + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + alpha = if (wp.isActive) 1.0f else 0.6f + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, bm) + infoWindow = null + setOnMarkerClickListener { _, _ -> + onWaypointTap(wp) + true + } + } + mv.overlays.add(marker) + } + + if (searchPin != null) { + val pinBm = makeSearchPinBitmapShared(96) + val pinMarker = Marker(mv).apply { + position = searchPin + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm) + title = SEARCH_PIN_TAG + infoWindow = null + setOnMarkerClickListener { _, _ -> true } + } + mv.overlays.add(pinMarker) + } + + mv.invalidate() +} + +private fun explorerTrackPointBitmap(sizePx: Int, argb: Int, selected: Boolean): Bitmap { + val size = sizePx.coerceAtLeast(16) + val bm = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val cx = size / 2f; val cy = size / 2f + val outerR = size / 2f - 2f + val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + alpha = 220 + } + canvas.drawCircle(cx, cy, outerR, halo) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = argb + } + canvas.drawCircle(cx, cy, outerR - size * 0.18f, fill) + if (selected) { + val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = 0xFF000000.toInt() + strokeWidth = (size * 0.08f).coerceAtLeast(3f) + } + canvas.drawCircle(cx, cy, outerR - size * 0.20f, ring) + } + return bm +} + +private fun explorerWaypointDotBitmap( + context: android.content.Context, + fillArgb: Int, + density: Float, + isSelected: Boolean +): Bitmap { + val touchDp = if (isSelected) 48f else 38f + val sizePx = (touchDp * density).toInt() + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val cx = sizePx / 2f + val cy = sizePx / 2f + val visibleRadius = (if (isSelected) 14f else 10f) * density + val halo = (if (isSelected) 4f else 3f) * density + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.style = Paint.Style.FILL + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius + halo, paint) + paint.color = fillArgb + canvas.drawCircle(cx, cy, visibleRadius, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density + paint.color = 0xFF202020.toInt() + canvas.drawCircle(cx, cy, visibleRadius, paint) + return bmp +} + +@Suppress("unused") +private fun explorerColorPlaceholder(): Color = Color.Unspecified + +/** + * v2.3.0 — Wegpunkt-Marker mit Audio-Längen-Ring außen. + * + * Ring-Sektor ist proportional zur normierten Audiodauer (0..1, gesättigt bei 120 s); + * Farbe codiert Bucket (kurz/mittel/lang). Innerer Punkt bleibt identisch zur + * Standard-Markerdarstellung, sodass kein visueller Bruch entsteht. + */ +private fun explorerWaypointDotWithDurationRing( + context: android.content.Context, + fillArgb: Int, + density: Float, + isSelected: Boolean, + durationMs: Long +): Bitmap { + val touchDp = if (isSelected) 60f else 50f + val sizePx = (touchDp * density).toInt().coerceAtLeast(48) + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val cx = sizePx / 2f + val cy = sizePx / 2f + val visibleRadius = (if (isSelected) 14f else 10f) * density + val halo = (if (isSelected) 4f else 3f) * density + val ringInner = visibleRadius + halo + 2f * density + val ringOuter = ringInner + 4f * density + val ringMid = (ringInner + ringOuter) / 2f + val ringStroke = (ringOuter - ringInner) + + // Hintergrund-Spur (transparent grau) + val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = ringStroke + color = 0x33000000.toInt() + } + canvas.drawCircle(cx, cy, ringMid, track) + + // Bucket-Farbe + val bucket = WaypointAudioDuration.classify(durationMs) + val bucketColor = when (bucket) { + de.waypointaudio.util.AudioLengthBucket.SHORT -> 0xFF66BB6A.toInt() // grün + de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 0xFFFFA726.toInt() // orange + de.waypointaudio.util.AudioLengthBucket.LONG -> 0xFFE53935.toInt() // rot + else -> fillArgb + } + val fraction = WaypointAudioDuration.normalizedFraction(durationMs).coerceIn(0f, 1f) + val sweep = 360f * fraction + if (sweep > 0.5f) { + val progress = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeWidth = ringStroke + color = bucketColor + } + val rectF = android.graphics.RectF(cx - ringMid, cy - ringMid, cx + ringMid, cy + ringMid) + canvas.drawArc(rectF, -90f, sweep, false, progress) + } + + // Innerer Marker (analog explorerWaypointDotBitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.style = Paint.Style.FILL + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius + halo, paint) + paint.color = fillArgb + canvas.drawCircle(cx, cy, visibleRadius, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density + paint.color = 0xFF202020.toInt() + canvas.drawCircle(cx, cy, visibleRadius, paint) + + return bmp +} + +/** + * v2.3.1 — Projiziert einen Punkt entlang eines Bearings über `distanceM` + * Meter mit dem Großkreis-Modell (Vincenty/Forward formula, hinreichend genau + * für Distanzen <500 m). Wird für Clip-Längenlinien verwendet. + */ +private fun projectGeo( + latDeg: Double, + lonDeg: Double, + bearingDeg: Double, + distanceM: Double +): GeoPoint { + val earthR = 6_371_000.0 + val angDist = distanceM / earthR + val br = Math.toRadians(bearingDeg) + val lat1 = Math.toRadians(latDeg) + val lon1 = Math.toRadians(lonDeg) + val lat2 = Math.asin( + Math.sin(lat1) * Math.cos(angDist) + + Math.cos(lat1) * Math.sin(angDist) * Math.cos(br) + ) + val lon2 = lon1 + Math.atan2( + Math.sin(br) * Math.sin(angDist) * Math.cos(lat1), + Math.cos(angDist) - Math.sin(lat1) * Math.sin(lat2) + ) + return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) +} + +/** + * Berechnet das Anfangsbearing in Grad (0=Nord, 90=Ost) von Punkt A nach B. + */ +private fun computeBearing( + lat1Deg: Double, + lon1Deg: Double, + lat2Deg: Double, + lon2Deg: Double +): Double { + val lat1 = Math.toRadians(lat1Deg) + val lat2 = Math.toRadians(lat2Deg) + val dLon = Math.toRadians(lon2Deg - lon1Deg) + val y = Math.sin(dLon) * Math.cos(lat2) + val x = Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon) + val br = Math.toDegrees(Math.atan2(y, x)) + return (br + 360.0) % 360.0 +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt new file mode 100644 index 0000000..02e0cce --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt @@ -0,0 +1,3856 @@ +package de.waypointaudio.ui + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.os.Looper +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AddLocation +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.GpsOff +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.NearMe +import androidx.compose.material.icons.filled.Navigation +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import de.waypointaudio.R +import de.waypointaudio.data.ExplorerSettingsStore +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.MapBaseStyle +import de.waypointaudio.data.MapStyleSettings +import de.waypointaudio.data.MapStyleStore +import de.waypointaudio.data.PoiCategory +import de.waypointaudio.data.PoiLayerSettings +import de.waypointaudio.data.TrackDraft +import de.waypointaudio.data.Waypoint +import de.waypointaudio.util.ExplorerPoiClient +import de.waypointaudio.util.ExplorerPoiResult +import de.waypointaudio.util.LatLngBbox +import de.waypointaudio.util.WaypointAudioDuration +import de.waypointaudio.viewmodel.WaypointViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.maplibre.android.annotations.IconFactory +import org.maplibre.android.annotations.Marker +import org.maplibre.android.annotations.MarkerOptions +import org.maplibre.android.annotations.Polygon +import org.maplibre.android.annotations.PolygonOptions +import org.maplibre.android.annotations.Polyline +import org.maplibre.android.annotations.PolylineOptions +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style + +/** + * v2.4.0 — MapLibre Engine Cutover. + * + * Aktiver Renderer im Explorer ist jetzt MapLibre Native (org.maplibre.gl:android-sdk:11.5.0). + * Die osmdroid-basierte Variante in [ExplorerScreen] bleibt im Quelltext erhalten und + * dient ausschließlich als interner Fallback — die Standard-Navigation (siehe + * [MainActivity]) ruft nun [ExplorerScreenMapLibre] auf. + * + * Funktionsumfang (Parität zu v2.3.1): + * - Wegpunkt-Marker (Auswahl/Aktionsdialog mit Clip-Playback) + * - Wegpunkt setzen via FAB / Tap / LongPress + * - Eigener Standort + Genauigkeitskreis + Follow-Schalter + * - Trigger-Radius/Audiozone je Waypoint + * - Audio-Längen-Ring/Badge (am Marker, layerable) + * - GPS-Aufzeichnung als Polyline (live/Draft/persistiert) + * - Routing-Polyline (OSRM-Endpunkt) + POI-MVP + * - Storyboard-/Planlinie unverändert über BottomSheet + * - Suche, Drafts-Sheet, persistente Follow-Einstellung + * + * Wesentliche Unterschiede zum osmdroid-Renderer: + * - Karten-Tiles werden als raster source/layer programmatisch erzeugt (kein API-Key). + * - Perspektive (3D-Vorschau) nutzt jetzt echte MapLibre Pitch/Tilt. + * - Map-Listener für Follow-Disable verwenden onMoveStart/CameraIdle. + * - Wegpunkt-/Track-Tap läuft über setOnMarkerClickListener. + * + * Clip-Längenlinien (v2.4.0 Korrektur): Diese werden nur noch dann gezeichnet, wenn + * eine bekannte Geometrie vorliegt (aufgezeichneter Track, Draft, berechnete Route). + * Ohne Geometrie wird ausschließlich Trigger-Radius/Audiodauer-Badge angezeigt + * — keine frei projizierten Fantasielinien mehr. + */ +@SuppressLint("MissingPermission") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExplorerScreenMapLibre( + viewModel: WaypointViewModel, + onNavigateBack: () -> Unit, + onOpenInEditor: () -> Unit, + onOpenDraft: (String) -> Unit = {}, + currentMode: MapMode? = null, + onModeChange: ((MapMode) -> Unit)? = null +) { + val context = LocalContext.current + val allWaypoints by viewModel.waypoints.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val tourList by viewModel.tourList.collectAsState() + val tourDefaults by viewModel.tourDefaults.collectAsState() + val isRecording by viewModel.isRecording.collectAsState() + val currentTrack by viewModel.currentTrack.collectAsState() + val persistedTrack by viewModel.persistedTrack.collectAsState() + val drafts by viewModel.trackDrafts.collectAsState() + val lastSavedDraftId by viewModel.lastSavedDraftId.collectAsState() + val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState() + val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() + // v2.4.6 — Aktive Tourroute (persistente Route der ausgewählten Tour). + val activeTourRoute by viewModel.activeTourRoute.collectAsState() + + val waypoints = allWaypoints.filter { + it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour + } + + 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() + ) + + val mapStyleStore = remember { MapStyleStore(context.applicationContext) } + val mapStyleSettings by mapStyleStore.settings.collectAsState( + initial = MapStyleSettings() + ) + val scope = rememberCoroutineScope() + + // Audio-Dauer-Cache je Wegpunkt-URI. + val audioDurationsMs = remember { mutableStateMapOf() } + LaunchedEffect(waypoints.map { it.soundUri }) { + for (wp in waypoints) { + val uri = wp.soundUri + if (uri.isBlank()) continue + if (audioDurationsMs.containsKey(uri)) continue + val d = withContext(Dispatchers.IO) { + WaypointAudioDuration.resolveDurationMs(context, uri) + } + audioDurationsMs[uri] = d + } + } + + var showRoutingInfo by remember { mutableStateOf(false) } + var showRoutePlanner by remember { mutableStateOf(false) } + var routeLine by remember { mutableStateOf>(emptyList()) } + var routeStatus by remember { mutableStateOf(null) } + var routeLoading by remember { mutableStateOf(false) } + var showRouteOverlay by remember { mutableStateOf(true) } + var routePlan by remember { mutableStateOf(RoutePlan()) } + // v2.4.6 — Distanz/Dauer der zuletzt berechneten Route (für persistente Speicherung). + var lastComputedDistanceMeters by remember { mutableStateOf(null) } + var lastComputedDurationSeconds by remember { mutableStateOf(null) } + var lastComputedEndpoint by remember { mutableStateOf("") } + + // v2.5.35 — Phantom-GPX-Line-Fix: Im neutralen Standard-Kontext (keine laufende + // Aufzeichnung, kein aktiver Draft) dürfen keine persistierten Linien angezeigt + // werden. isNeutralStandard ist true, wenn die aktuelle Tour die Standardtour ist + // UND kein eigener aktiver Kontext (Aufzeichnung / Draft) vorliegt. + // isNeutralStandard wird auch für visibleTrack verwendet (s. u.). + val isNeutralStandard: Boolean = + selectedTour == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + + // 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) + // gezeichnet und über manualRoutePoints dargestellt. Hier liefern wir + // sie deshalb NICHT als saved-route, damit sie nicht doppelt erscheinen. + // 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. + 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). + 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, + // 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) + routeLine = emptyList() + routeStatus = null + lastComputedDistanceMeters = null + lastComputedDurationSeconds = null + lastComputedEndpoint = "" + } + // 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). + val routePlanline: List = remember(routePlan) { + routePlan.orderedStops().map { LatLng(it.latitude, it.longitude) } + .takeIf { it.size >= 2 } ?: emptyList() + } + val routingPoiCtx = LocalContext.current + + // MapLibre-spezifische State-Halter + var mapView by remember { mutableStateOf(null) } + var mapLibreMap by remember { mutableStateOf(null) } + var styleReady by remember { mutableStateOf(false) } + + // Annotation-Caches: wir entfernen/erzeugen pro Update neu, halten aber die + // Referenzen zu den eigenen Annotations, um sie gezielt löschen zu können. + val waypointMarkers = remember { mutableListOf() } + val waypointMarkerIdToWaypoint = remember { mutableMapOf() } + val waypointRadiusPolygons = remember { mutableListOf() } + val trackMarkers = remember { mutableListOf() } + val trackMarkerIdToIndex = remember { mutableMapOf() } + val trackPolylines = remember { mutableListOf() } + val routePolylines = remember { mutableListOf() } + val planlinePolylines = remember { mutableListOf() } + val routeStopMarkers = remember { mutableListOf() } + val audioLinePolylines = remember { mutableListOf() } + // v2.5.3 — Annotations der manuell gezeichneten Route. + val manualRoutePolylines = remember { mutableListOf() } + val manualRouteMarkers = remember { mutableListOf() } + val manualRouteMarkerIdToIndex = remember { mutableMapOf() } + var myLocationMarker by remember { mutableStateOf(null) } + var accuracyPolygon by remember { mutableStateOf(null) } + var searchPinMarker by remember { mutableStateOf(null) } + + // v2.5.40 — Explorer-POI-State + val explorerPoiMarkers = remember { mutableListOf() } + val explorerPoiMarkerIdToResult = remember { mutableMapOf() } + var explorerPoiResults by remember { mutableStateOf>>(emptyMap()) } + var explorerPoiLoading by remember { mutableStateOf(false) } + var explorerPoiSnackbar by remember { mutableStateOf(null) } + var selectedExplorerPoi by remember { mutableStateOf(null) } + // v2.5.42 — Legende standardmäßig eingeklappt + var poiLegendExpanded by remember { mutableStateOf(false) } + var currentMapZoom by remember { mutableStateOf(13.0) } + val snackbarHostState = remember { SnackbarHostState() } + + var lastLat by remember { mutableStateOf(null) } + var lastLng by remember { mutableStateOf(null) } + var lastAccuracyM by remember { mutableStateOf(null) } + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) + } + + var followLocation by remember { mutableStateOf(persistedSettings.followLocation) } + var followInitialized by remember { mutableStateOf(false) } + LaunchedEffect(persistedSettings.followLocation) { + if (!followInitialized) { + followLocation = persistedSettings.followLocation + followInitialized = true + } + } + + var actionMenuWaypoint by remember { mutableStateOf(null) } + var editDialogWaypoint by remember { mutableStateOf(null) } + var editDialogPrefill by remember { mutableStateOf?>(null) } + var showEditDialog by remember { mutableStateOf(false) } + var deleteConfirm by remember { mutableStateOf(null) } + var tapMode by remember { mutableStateOf(TapModeMl.None) } + + // v2.5.3 — Manueller Routemodus ("Route zeichnen"). + // Die `manualRoutePoints`-Liste hält den aktuellen Wegverlauf rein lokal + // (LatLng). Stille Punkte: Sie erscheinen NICHT in der Wegpunkt-/Track-Liste, + // lösen KEIN Audio aus, beeinflussen KEINE Clip-/Audio-Funktionen. + // Persistenz: bei jeder Änderung wird in [TourRoute] mit + // [RouteSourceKind.MANUAL] gespeichert. Beim Tour-Wechsel laden wir die + // gespeicherte manuelle Route falls vorhanden. + var manualRouteMode by remember { mutableStateOf(false) } + var manualRoutePoints by remember { mutableStateOf>(emptyList()) } + var selectedRoutePointIndex by remember { mutableStateOf(null) } + var showRoutePointDialog by remember { mutableStateOf(false) } + + // Beim Tour-Wechsel: bestehende 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) { + val r = activeTourRoute + manualRoutePoints = if ( + r?.source == de.waypointaudio.data.RouteSourceKind.MANUAL + && !isNeutralStandard // Phantom-Guard: Standardtour bleibt leer + ) { + 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 + } + + // 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) { + val currentSaved = activeTourRoute + val wasManual = currentSaved?.source == de.waypointaudio.data.RouteSourceKind.MANUAL + if (manualRoutePoints.isEmpty()) { + if (wasManual) viewModel.deleteTourRouteForCurrent() + return@LaunchedEffect + } + val polyline = manualRoutePoints.map { + de.waypointaudio.data.TourRoutePoint(lat = it.latitude, lon = it.longitude) + } + val updated = de.waypointaudio.data.TourRoute( + id = currentSaved?.id?.takeIf { wasManual } + ?: java.util.UUID.randomUUID().toString(), + polyline = polyline, + source = de.waypointaudio.data.RouteSourceKind.MANUAL, + endpoint = "Manuell gezeichnet", + profile = currentSaved?.profile?.takeIf { wasManual } ?: "", + createdAtMillis = currentSaved?.createdAtMillis?.takeIf { wasManual } + ?: System.currentTimeMillis(), + updatedAtMillis = System.currentTimeMillis(), + ) + viewModel.saveTourRouteForCurrent(updated) + } + + val puckBitmap = remember { explorerMlLocationPuckBitmap(context) } + + var layerState by remember { + mutableStateOf(MapLayerState(followLocation = persistedSettings.followLocation)) + } + var styleHydrated by remember { mutableStateOf(false) } + LaunchedEffect(mapStyleSettings) { + if (!styleHydrated) { + // v2.5.38 — Beim ersten Laden alle persistierten Einstellungen übernehmen, + // inkl. baseStyle (Kartentyp-Persistenz) und poiLayers. + layerState = layerState.copy( + baseStyle = mapStyleSettings.baseStyle, + showAudioLength = mapStyleSettings.showAudioLength, + showAudioLines = mapStyleSettings.showAudioLines, + poiLayers = mapStyleSettings.poiLayers, + ) + styleHydrated = true + } + } + LaunchedEffect(followLocation) { + if (layerState.followLocation != followLocation) { + layerState = layerState.copy(followLocation = followLocation) + } + runCatching { settingsStore.setFollowLocation(followLocation) } + } + LaunchedEffect(layerState.followLocation) { + if (followLocation != layerState.followLocation) { + followLocation = layerState.followLocation + } + } + LaunchedEffect(layerState.baseStyle) { + if (styleHydrated && layerState.baseStyle != mapStyleSettings.baseStyle) { + runCatching { mapStyleStore.setBaseStyle(layerState.baseStyle) } + } + } + LaunchedEffect(layerState.showAudioLength) { + if (styleHydrated && layerState.showAudioLength != mapStyleSettings.showAudioLength) { + runCatching { mapStyleStore.setShowAudioLength(layerState.showAudioLength) } + } + } + LaunchedEffect(layerState.showAudioLines) { + if (styleHydrated && layerState.showAudioLines != mapStyleSettings.showAudioLines) { + runCatching { mapStyleStore.setShowAudioLines(layerState.showAudioLines) } + } + } + // v2.5.38 — POI-Layer-Auswahl persistent speichern. + LaunchedEffect(layerState.poiLayers) { + if (styleHydrated && layerState.poiLayers != mapStyleSettings.poiLayers) { + runCatching { mapStyleStore.setPoiLayers(layerState.poiLayers) } + } + } + // 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). + LaunchedEffect(layerState.poiLayers, layerState.baseStyle, styleReady) { + if (!styleReady) return@LaunchedEffect + val ml = mapLibreMap ?: return@LaunchedEffect + val style = ml.style ?: return@LaunchedEffect + // Klassisch (Raster): keine Vektor-Layer anwendbar. + if (layerState.baseStyle == MapBaseStyle.CLASSIC || + layerState.baseStyle == MapBaseStyle.SATELLITE) return@LaunchedEffect + applyPoiLayerVisibility(style, layerState.poiLayers) + } + + // v2.5.40 — Snackbar-Anzeige für Explorer-POI-Meldungen + LaunchedEffect(explorerPoiSnackbar) { + val msg = explorerPoiSnackbar ?: return@LaunchedEffect + snackbarHostState.showSnackbar(msg) + explorerPoiSnackbar = null + } + + // v2.5.42 — Namen-Labels über Toggle (nicht mehr zoom-abhängig automatisch) + val showPoiNames = layerState.poiLayers.showPoiNames + + // v2.5.42 — Explorer-POI-Marker zeichnen/aktualisieren. + // Schlüssel inkl. showPoiNames: Marker werden bei Toggle-Änderung neu gezeichnet. + // Fix: Kategorie-Bitmap wird per buildExplorerPoiIcon(cat, showPoiNames, name) mit + // eindeutiger Kategorie-Kennung erzeugt — verhindert Icon-Cache-Kollisionen in MapLibre. + LaunchedEffect(explorerPoiResults, styleReady, showPoiNames) { + if (!styleReady) return@LaunchedEffect + val ml = mapLibreMap ?: return@LaunchedEffect + // Alte Explorer-POI-Marker entfernen + explorerPoiMarkers.forEach { runCatching { ml.removeMarker(it) } } + explorerPoiMarkers.clear() + explorerPoiMarkerIdToResult.clear() + if (explorerPoiResults.isEmpty()) return@LaunchedEffect + val iconFactory = IconFactory.getInstance(context) + for ((cat, pois) in explorerPoiResults) { + for (poi in pois) { + val iconBitmap = if (showPoiNames) { + buildExplorerPoiIconWithLabel(cat, poi.displayName) + } else { + buildExplorerPoiIcon(cat) + } + val icon = iconFactory.fromBitmap(iconBitmap) + val mo = MarkerOptions() + .position(LatLng(poi.latitude, poi.longitude)) + .title(poi.displayName) + .snippet(poi.categoryLabel) + .icon(icon) + val marker = runCatching { ml.addMarker(mo) }.getOrNull() ?: continue + explorerPoiMarkers.add(marker) + explorerPoiMarkerIdToResult[marker.id] = poi + } + } + } + + var showSearchDialog by remember { mutableStateOf(false) } + var searchPin by remember { mutableStateOf(null) } + var showDraftsSheet by remember { mutableStateOf(false) } + var showStoryboardSheet by remember { mutableStateOf(false) } + var showDraftPointMenu by remember { mutableStateOf(null) } + var activeDraftId by remember { mutableStateOf(null) } + val activeDraft = activeDraftId?.let { id -> drafts.firstOrNull { it.id == id } } + var selectedTrackIndex by remember { mutableStateOf(null) } + + LaunchedEffect(lastSavedDraftId) { + val id = lastSavedDraftId + if (id != null) { + activeDraftId = id + showDraftsSheet = true + viewModel.consumeLastSavedDraftId() + } + } + + // v2.5.36 — Phantom-Guard für persistedTrack (verschärft): + // Im neutralen Standard-Kontext (Standardtour, keine Aufzeichnung, kein Draft) + // darf KEIN Track angezeigt werden — weder persistedTrack noch currentTrack + // aus dem TrackRecordingManager, der beim Tour-Wechsel mit _persistedTrack + // befüllt wird (v2.5.33-Logik). Die isNeutralStandard-Prüfung muss deshalb + // VOR der currentTrack.isNotEmpty()-Prüfung stehen, da der Manager-Track + // sonst die Guard umgeht und die weiße Phantom-Linie weiterhin erscheint. + val visibleTrack: List = when { + isRecording -> currentTrack + activeDraft != null -> activeDraft.points + // Phantom-Guard (v2.5.36): Standardtour ohne aktive Aufzeichnung/Draft + // → ALLE Tracks (inkl. currentTrack/Manager-Track) unterdrücken. + // Wird bewusst vor currentTrack.isNotEmpty() geprüft, damit der + // Manager-Track (aus loadPersistedTrackForCurrentTour) nicht durch + // die currentTrack-Prüfung sichtbar wird. + isNeutralStandard -> emptyList() + currentTrack.isNotEmpty() -> currentTrack + else -> persistedTrack + } + val visibleMarkedIndexes: List = activeDraft?.markedIndexes ?: emptyList() + + // v2.5.37 — Style nachziehen, wenn Basis-Stil wechselt. + // OpenFreeMap Vektor-Stile werden über Style.Builder().fromUri() geladen. + // 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. + LaunchedEffect( + layerState.baseStyle, + mapStyleSettings.perspectiveTilt, + styleReady + ) { + val ml = mapLibreMap ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + + // Phantom-Layer-Cleanup vor Stilwechsel. + styleReady = false + clearAllAnnotationsCaches( + waypointMarkers, waypointMarkerIdToWaypoint, waypointRadiusPolygons, + trackMarkers, trackMarkerIdToIndex, trackPolylines, routePolylines, + audioLinePolylines, + manualRoutePolylines, manualRouteMarkers, manualRouteMarkerIdToIndex + ) + myLocationMarker = null + accuracyPolygon = null + searchPinMarker = null + + val pitchDeg = if (layerState.baseStyle == MapBaseStyle.CITY_3D || + layerState.baseStyle == MapBaseStyle.TERRAIN_3D || + layerState.baseStyle == MapBaseStyle.PERSPECTIVE) { + mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0) + } else 0.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) { + 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) { + try { + applyPoiLayerVisibility(loadedStyle, layerState.poiLayers) + } catch (_: Exception) { /* Kein Crash wenn Layer nicht vorhanden */ } + } + // Kamera-Pitch setzen. + val pos = ml.cameraPosition + ml.cameraPosition = CameraPosition.Builder(pos).tilt(pitchDeg).build() + styleReady = true + } + + when (layerState.baseStyle) { + MapBaseStyle.MODERN_2D -> { + ml.setStyle( + Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron"), + onStyleReady + ) + } + MapBaseStyle.DARK -> { + ml.setStyle( + Style.Builder().fromUri("https://tiles.openfreemap.org/styles/dark"), + onStyleReady + ) + } + MapBaseStyle.OUTDOOR, MapBaseStyle.TOPO -> { + ml.setStyle( + Style.Builder().fromUri("https://tiles.openfreemap.org/styles/liberty"), + onStyleReady + ) + } + MapBaseStyle.CITY_3D, MapBaseStyle.TERRAIN_3D, MapBaseStyle.PERSPECTIVE -> { + // Positron als Basis für 3D-Stadt — nach Laden Gebäude-Extrusion einfügen. + ml.setStyle( + Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron"), + onStyleReady + ) + } + MapBaseStyle.CLASSIC, MapBaseStyle.SATELLITE -> { + // Klassisch: OSM-Raster-Fallback (JSON-basiert, kein Vektor). + val classicJson = buildClassicOsmStyleJson() + ml.setStyle(Style.Builder().fromJson(classicJson), onStyleReady) + } + } + } + + // Berechnete Routenlinie + Planlinie + Stop-Marker als eigene Polyline-Schicht. + LaunchedEffect(routeLine, routePlanline, routePlan, showRouteOverlay, styleReady, savedRouteLine) { + val ml = mapLibreMap ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + 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() + + 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). + 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.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 + } + + 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 + } + + val iconFactory = IconFactory.getInstance(context) + // Stop-Marker für Start/Via/Ziel + 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)) + ) + } + 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)) + ) + } + 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)) + ) + } + } + + // v2.5.3 — Manuelle Route als eigene Schicht zeichnen. + // Stille Routenpunkte (kleine, dunkle Kreise) + verbindende Linie. + // Diese Punkte sind KEINE Wegpunkte und werden niemals in der + // Wegpunktliste, Track-Liste oder Storyboard erscheinen. + LaunchedEffect(manualRoutePoints, manualRouteMode, styleReady, + selectedRoutePointIndex, primaryArgb) { + val ml = mapLibreMap ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + for (pl in manualRoutePolylines) runCatching { ml.removePolyline(pl) } + manualRoutePolylines.clear() + for (m in manualRouteMarkers) runCatching { ml.removeMarker(m) } + manualRouteMarkers.clear() + manualRouteMarkerIdToIndex.clear() + + if (manualRoutePoints.size >= 2) { + val halo = ml.addPolyline( + PolylineOptions() + .addAll(manualRoutePoints) + .color(0xFFFFFFFF.toInt()) + .width(8f) + .alpha(0.75f) + ) + val line = ml.addPolyline( + PolylineOptions() + .addAll(manualRoutePoints) + // Klar unterscheidbar von OSRM-Routen (blau) und gespeicherten + // Tourrouten (grün): violett für "manuell gezeichnet". + .color(0xFF6A1B9A.toInt()) + .width(5f) + .alpha(0.95f) + ) + manualRoutePolylines += halo + manualRoutePolylines += line + } + + // Punkt-Marker pro Routenpunkt – klein, ohne Audio-/Wegpunkt-Bezug. + if (manualRoutePoints.isNotEmpty()) { + val iconFactory = IconFactory.getInstance(context) + val sizePx = (context.resources.displayMetrics.density * 14).toInt() + manualRoutePoints.forEachIndexed { idx, p -> + val selected = selectedRoutePointIndex == idx + val bm = trackPointBitmap(sizePx, 0xFF6A1B9A.toInt(), selected) + val marker = ml.addMarker( + MarkerOptions() + .position(p) + .title("Routenpunkt ${idx + 1}") + .icon(iconFactory.fromBitmap(bm)) + ) + manualRouteMarkers += marker + manualRouteMarkerIdToIndex[marker.id] = idx + } + } + } + + // Haupt-Overlay-Refresh: Wegpunkte, Tracks, Audiozonen, Audio-Längen-Linien, + // sowie der Search-Pin. + LaunchedEffect( + waypoints, + primaryArgb, + actionMenuWaypoint?.id, + visibleTrack, + visibleMarkedIndexes, + selectedTrackIndex, + searchPin, + layerState, + audioDurationsMs.toMap(), + routeLine, + routePlanline, + savedRouteLine, + manualRoutePoints, + styleReady + ) { + val ml = mapLibreMap ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + drawExplorerOverlaysMapLibre( + ml = ml, + context = context, + waypoints = if (layerState.showMarkers) waypoints else emptyList(), + track = if (layerState.showTrack) visibleTrack else emptyList(), + markedIndexes = visibleMarkedIndexes, + selectedTrackIndex = selectedTrackIndex, + primaryArgb = primaryArgb, + trackArgb = tertiaryArgb, + showRadius = layerState.showRadius, + showAudioLength = layerState.showAudioLength, + showAudioLines = layerState.showAudioLines, + audioDurationsMs = audioDurationsMs.toMap(), + selectedId = actionMenuWaypoint?.id, + searchPin = searchPin, + // v2.4.6: Geometrie für Clip-Längenlinien. + // Reihenfolge der Priorität (laut Architektur-Abstimmung): + // 1) Aktuell berechnete Routenlinie (routeLine), wenn vorhanden. + // 2) Gespeicherte Tourroute (savedRouteLine), wenn vorhanden. + // 3) Sichtbarer Track/Draft (visibleTrack), wenn mind. 2 Punkte. + // 4) Explizite Planlinie (routePlanline) als letzte Notlösung. + // 5) sonst NULL → keine Clip-Linien (nur Radius/Audio-Badge). + // Damit dominiert eine frisch berechnete Route, dann die fest an + // der Tour gespeicherte Route, dann der reale Track, und nur ganz + // zum Schluss eine Planlinie (Luftlinie). Keine freien Linien. + clipLineGeometry = when { + routeLine.size >= 2 -> routeLine + savedRouteLine.size >= 2 -> savedRouteLine + // v2.5.3 — Manuell gezeichnete Route gilt als Tourverlauf für + // Clip-Längen entlang bekannter Geometrie. Audio-Waypoints + // werden weiterhin separat auf Basis ihres soundUri/Dauer + // gerendert — stille Routenpunkte erzeugen keine Clip-Linien. + manualRoutePoints.size >= 2 -> manualRoutePoints + visibleTrack.size >= 2 -> visibleTrack.map { LatLng(it.latitude, it.longitude) } + routePlanline.size >= 2 -> routePlanline + else -> null + }, + waypointMarkers = waypointMarkers, + waypointMarkerIdToWaypoint = waypointMarkerIdToWaypoint, + waypointRadiusPolygons = waypointRadiusPolygons, + trackMarkers = trackMarkers, + trackMarkerIdToIndex = trackMarkerIdToIndex, + trackPolylines = trackPolylines, + audioLinePolylines = audioLinePolylines, + searchPinMarkerRef = { it -> searchPinMarker = it }, + currentSearchPinMarker = searchPinMarker + ) + } + + // Standort-Updates: My-Marker, Genauigkeitskreis, ggf. Follow. + DisposableEffect(hasPermission) { + if (!hasPermission) return@DisposableEffect onDispose { } + val client = LocationServices.getFusedLocationProviderClient(context) + val req = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000L) + .setMinUpdateIntervalMillis(1_000L) + .build() + val callback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + val loc = result.lastLocation ?: return + lastLat = loc.latitude + lastLng = loc.longitude + lastAccuracyM = if (loc.hasAccuracy()) loc.accuracy else null + val ml = mapLibreMap + if (ml != null && styleReady) { + val gp = LatLng(loc.latitude, loc.longitude) + myLocationMarker?.let { runCatching { ml.removeMarker(it) } } + myLocationMarker = ml.addMarker( + MarkerOptions() + .position(gp) + .title("Mein Standort") + .icon( + puckBitmap?.let { IconFactory.getInstance(context).fromBitmap(it) } + ?: IconFactory.getInstance(context).defaultMarker() + ) + ) + accuracyPolygon?.let { runCatching { ml.removePolygon(it) } } + val radius = lastAccuracyM?.toDouble() ?: 0.0 + accuracyPolygon = if (radius > 0.0) { + ml.addPolygon( + PolygonOptions() + .addAll(circleLatLng(loc.latitude, loc.longitude, radius, 60)) + .fillColor((primaryArgb and 0x00FFFFFF) or 0x33000000) + .strokeColor(primaryArgb) + .alpha(0.4f) + ) + } else null + if (followLocation) { + val current = ml.cameraPosition + ml.animateCamera( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder(current) + .target(gp) + .build() + ) + ) + } + } + } + } + runCatching { + client.requestLocationUpdates(req, callback, Looper.getMainLooper()) + } + onDispose { + runCatching { client.removeLocationUpdates(callback) } + } + } + + // ─── Dialogs ───────────────────────────────────────────────────────────── + + actionMenuWaypoint?.let { wp -> + AlertDialog( + onDismissRequest = { actionMenuWaypoint = null }, + title = { + Text( + text = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }, + style = MaterialTheme.typography.titleMedium + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "%.6f, %.6f · %dm".format( + java.util.Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + val wpDuration = audioDurationsMs[wp.soundUri] ?: 0L + if (wpDuration > 0L) { + Text( + stringResource( + R.string.explorer_waypoint_audio_duration, + WaypointAudioDuration.formatShort(wpDuration) + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Button( + onClick = { + editDialogWaypoint = wp + editDialogPrefill = null + showEditDialog = true + actionMenuWaypoint = null + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_edit)) + } + Button( + onClick = { + tapMode = TapModeMl.MoveWaypoint(wp.id) + actionMenuWaypoint = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_tap)) + } + val hasClip = wp.soundUri.isNotBlank() + val isThisPlaying = hasClip && + singlePlayingWaypointId == wp.id && singlePlayingActive + Button( + onClick = { + if (hasClip) viewModel.manualPlaySingle(wp) + }, + enabled = hasClip, + colors = ButtonDefaults.buttonColors( + containerColor = if (isThisPlaying) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.tertiaryContainer, + contentColor = if (isThisPlaying) + MaterialTheme.colorScheme.onError + else MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + if (isThisPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + if (!hasClip) + stringResource(R.string.explorer_action_no_clip) + else if (isThisPlaying) + stringResource(R.string.explorer_action_pause_clip) + else + stringResource(R.string.explorer_action_play_clip) + ) + } + // v2.4.2 — Wegpunkt direkt in den Routenplaner übernehmen. + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { + routePlan = routePlan.copy( + start = RouteStop( + latitude = wp.latitude, longitude = wp.longitude, + label = wp.name.ifBlank { "Wegpunkt" }, + source = RouteStopSource.WAYPOINT + ) + ) + actionMenuWaypoint = null + showRoutePlanner = true + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.route_planner_set_as_start)) + } + TextButton( + onClick = { + routePlan = routePlan.copy( + via = routePlan.via + RouteStop( + latitude = wp.latitude, longitude = wp.longitude, + label = wp.name.ifBlank { "Wegpunkt" }, + source = RouteStopSource.WAYPOINT + ) + ) + actionMenuWaypoint = null + showRoutePlanner = true + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.route_planner_set_as_via)) + } + TextButton( + onClick = { + routePlan = routePlan.copy( + destination = RouteStop( + latitude = wp.latitude, longitude = wp.longitude, + label = wp.name.ifBlank { "Wegpunkt" }, + source = RouteStopSource.WAYPOINT + ) + ) + actionMenuWaypoint = null + showRoutePlanner = true + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.route_planner_set_as_destination)) + } + } + Button( + onClick = { + deleteConfirm = wp + actionMenuWaypoint = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.delete)) + } + } + }, + confirmButton = { + TextButton(onClick = { actionMenuWaypoint = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // v2.5.3 — Einfacher Punkt-Dialog für Routenpunkte (Verschieben/Löschen). + if (showRoutePointDialog) { + val idx = selectedRoutePointIndex + val pt = idx?.let { manualRoutePoints.getOrNull(it) } + if (idx == null || pt == null) { + showRoutePointDialog = false + } else { + AlertDialog( + onDismissRequest = { + showRoutePointDialog = false + selectedRoutePointIndex = null + }, + title = { Text(stringResource(R.string.explorer_route_point_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "%.6f, %.6f".format(java.util.Locale.US, pt.latitude, pt.longitude), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + Button( + onClick = { + tapMode = TapModeMl.MoveRoutePoint(idx) + showRoutePointDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_route_point_move)) + } + Button( + onClick = { + val ml = mapLibreMap + val center = ml?.cameraPosition?.target + if (center != null) { + val pts = manualRoutePoints.toMutableList() + if (idx in pts.indices) { + pts[idx] = LatLng(center.latitude, center.longitude) + manualRoutePoints = pts + } + } + showRoutePointDialog = false + selectedRoutePointIndex = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_to_center)) + } + Button( + onClick = { + val pts = manualRoutePoints.toMutableList() + if (idx in pts.indices) { + pts.removeAt(idx) + manualRoutePoints = pts + } + showRoutePointDialog = false + selectedRoutePointIndex = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_route_point_delete)) + } + } + }, + confirmButton = { + TextButton(onClick = { + showRoutePointDialog = false + selectedRoutePointIndex = null + }) { Text(stringResource(R.string.close)) } + } + ) + } + } + + deleteConfirm?.let { wp -> + AlertDialog( + onDismissRequest = { deleteConfirm = null }, + title = { Text(stringResource(R.string.confirm_delete_waypoint_title)) }, + text = { + Text( + stringResource( + R.string.confirm_delete_waypoint_msg, + wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) } + ) + ) + }, + confirmButton = { + Button( + onClick = { + viewModel.delete(wp.id) + deleteConfirm = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { Text(stringResource(R.string.delete)) } + }, + dismissButton = { + TextButton(onClick = { deleteConfirm = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showEditDialog) { + WaypointEditDialog( + waypoint = editDialogWaypoint, + prefillLatLng = if (editDialogWaypoint == null) editDialogPrefill else null, + existingTours = tourList, + prefillTourName = selectedTour, + tourDefaults = tourDefaults, + onConfirm = { newWaypoint -> + viewModel.upsert(newWaypoint) + showEditDialog = false + editDialogWaypoint = null + editDialogPrefill = null + }, + onDismiss = { + showEditDialog = false + editDialogWaypoint = null + editDialogPrefill = null + }, + // v2.5.16 — Kopieren auch aus MapLibre-Bearbeiten-Flow möglich. + onCopyToOtherTour = editDialogWaypoint?.let { wp -> + { targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) } + } + ) + } + + if (showRoutePlanner) { + val locationLatLon: Pair? = lastLat?.let { lat -> + lastLng?.let { lon -> lat to lon } + } + val mapCenterLatLon: Pair? = mapLibreMap?.cameraPosition?.target?.let { + it.latitude to it.longitude + } + RoutePlannerSheet( + plan = routePlan, + routeStatus = routeStatus, + hasRoute = routeLine.size >= 2, + hasPlanline = routePlanline.size >= 2, + isLoading = routeLoading, + routingEndpoint = mapStyleSettings.routingEndpoint, + showRouteOverlay = showRouteOverlay, + onShowRouteOverlayChange = { showRouteOverlay = it }, + onPlanChange = { newPlan -> + routePlan = newPlan + // Bei Plan-Änderung verwerfen wir die alte echte Route — sonst + // bliebe die alte Linie sichtbar, obwohl die Stops sich geändert haben. + routeLine = emptyList() + if (routeStatus != null) routeStatus = null + }, + onComputeRoute = { + // v2.4.4: Wenn kein Endpunkt gesetzt ist, geben wir keinen + // grauen Button mehr aus, sondern lassen den Nutzer auf + // 'Route berechnen' klicken und antworten mit einem klaren + // Status, der direkt auf den Demo-Quick-Setup verweist + // (siehe route_planner_status_endpoint_missing). + val endpoint = de.waypointaudio.util.OsrmRoutingClient + .normalizeBase(mapStyleSettings.routingEndpoint) + // v2.5.2 — gewähltes Profil für API-Aufruf, Status und Persistenz. + val profile = routePlan.profile + val profileLabel = routingPoiCtx.getString(profileLabelRes(profile)) + if (endpoint.isBlank()) { + routeStatus = routingPoiCtx.getString( + R.string.route_planner_status_endpoint_missing_with_profile, + profileLabel + ) + return@RoutePlannerSheet + } + val pts = routePlan.toOsrmPoints() + if (pts.size < 2) { + routeStatus = routingPoiCtx.getString(R.string.routing_compute_no_points) + return@RoutePlannerSheet + } + routeLoading = true + routeStatus = routingPoiCtx.getString(R.string.routing_compute_loading) + scope.launch { + runCatching { + de.waypointaudio.util.OsrmRoutingClient.route( + routingPoiCtx, endpoint, pts, profile.osrmKey + ) + }.onSuccess { result -> + routeLine = result.polyline.map { (lat, lon) -> LatLng(lat, lon) } + // v2.4.6 — Metadaten für die persistente Tourroute merken. + lastComputedDistanceMeters = result.distanceMeters + lastComputedDurationSeconds = result.durationSeconds + lastComputedEndpoint = endpoint + routeStatus = routingPoiCtx.getString( + R.string.routing_compute_success, + de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), + de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) + ) + }.onFailure { ex -> + routeLine = emptyList() + lastComputedDistanceMeters = null + lastComputedDurationSeconds = null + lastComputedEndpoint = "" + // v2.5.2: Fehlermeldung enthält das gewählte Profil, + // damit der Nutzer einordnen kann, ob z. B. das + // OSRM-Backend ein bestimmtes Profil nicht anbietet + // (typische Fehlerquelle bei eigenen Servern). + val detail = (ex.message ?: ex::class.java.simpleName).take(80) + routeStatus = routingPoiCtx.getString( + R.string.route_planner_status_error_with_profile, + profileLabel, detail + ) + } + routeLoading = false + } + }, + onUseDemoEndpoint = { + // v2.4.4: Quick-Setup für den OSRM-Demo-Server. Setzt einmalig + // den Endpunkt — der Nutzer kann ihn jederzeit unter „Endpunkt + // einrichten" durch einen eigenen self-hosted Server ersetzen. + scope.launch { + runCatching { + mapStyleStore.setRoutingEndpoint( + de.waypointaudio.util.OsrmRoutingClient.DEMO_ENDPOINT + ) + } + routeStatus = routingPoiCtx.getString(R.string.route_planner_demo_endpoint_set) + } + }, + onShowPlanline = { + routeLine = emptyList() + if (routePlanline.size >= 2) { + routeStatus = routingPoiCtx.getString(R.string.route_planner_planline_active) + val ml = mapLibreMap + if (ml != null) { + val mid = routePlanline[routePlanline.size / 2] + ml.animateCamera(CameraUpdateFactory.newLatLngZoom(mid, 13.0)) + } + } + }, + onClear = { + routePlan = RoutePlan() + routeLine = emptyList() + routeStatus = null + }, + onConfigureEndpoint = { + showRoutePlanner = false + showRoutingInfo = true + }, + onSaveAsTourDraft = { tourName -> + val orderedStops = routePlan.orderedStops() + if (orderedStops.isEmpty()) return@RoutePlannerSheet + // Wegpunkte aus den Stops anlegen und in neue Tour ablegen. + val target = tourName.ifBlank { "Routenentwurf" } + val newWaypoints = orderedStops.mapIndexed { i, stop -> + Waypoint( + name = "${stop.label.take(40)} #${i + 1}", + latitude = stop.latitude, + longitude = stop.longitude, + radiusMeters = 40f, + tourName = target, + isActive = true + ) + } + for (wp in newWaypoints) viewModel.upsert(wp) + // v2.4.6 — Wenn aus dem Storyboard/Tour-Entwurf eine Tour mit + // Geometrie entsteht, optional auch eine Tourroute speichern. + // Verwendet die aktuell sichtbare Geometrie (Route oder + // Planlinie). Keine automatische Audioerzeugung. + val tourRoute = buildTourRouteFromState( + plan = routePlan, + routeLine = routeLine, + routePlanline = routePlanline, + distanceMeters = lastComputedDistanceMeters, + durationSeconds = lastComputedDurationSeconds, + endpoint = lastComputedEndpoint, + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (tourRoute != null) { + viewModel.saveTourRouteFor(target, tourRoute) + } + routeStatus = routingPoiCtx.getString( + R.string.route_planner_save_as_tour_done, target + ) + }, + onAddStopsAsWaypoints = { + val orderedStops = routePlan.orderedStops() + if (orderedStops.isEmpty()) return@RoutePlannerSheet + val target = selectedTour + var count = 0 + for ((i, stop) in orderedStops.withIndex()) { + viewModel.upsert( + Waypoint( + name = "${stop.label.take(40)} #${i + 1}", + latitude = stop.latitude, + longitude = stop.longitude, + radiusMeters = 40f, + tourName = target, + isActive = true + ) + ) + count++ + } + routeStatus = routingPoiCtx.getString(R.string.route_planner_add_stops_done, count) + }, + // v2.4.6 — Tourrouten-Aktionen + currentTourName = selectedTour, + savedTourRoute = activeTourRoute, + onSaveTourRoute = { + val route = buildTourRouteFromState( + plan = routePlan, + routeLine = routeLine, + routePlanline = routePlanline, + distanceMeters = lastComputedDistanceMeters, + durationSeconds = lastComputedDurationSeconds, + endpoint = lastComputedEndpoint, + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (route != null) { + viewModel.saveTourRouteForCurrent(route) + routeStatus = routingPoiCtx.getString(R.string.tour_route_saved_msg, selectedTour) + } + }, + onReplaceTourRoute = { + val route = buildTourRouteFromState( + plan = routePlan, + routeLine = routeLine, + routePlanline = routePlanline, + distanceMeters = lastComputedDistanceMeters, + durationSeconds = lastComputedDurationSeconds, + endpoint = lastComputedEndpoint, + planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) + ) + if (route != null) { + viewModel.saveTourRouteForCurrent(route) + routeStatus = routingPoiCtx.getString(R.string.tour_route_replaced_msg, selectedTour) + } + }, + onDeleteTourRoute = { + viewModel.deleteTourRouteForCurrent() + routeStatus = routingPoiCtx.getString(R.string.tour_route_deleted_msg, selectedTour) + }, + currentLocationLatLon = locationLatLon, + mapCenterLatLon = mapCenterLatLon, + availableWaypoints = waypoints, + onDismiss = { showRoutePlanner = false } + ) + } + + if (showRoutingInfo) { + RoutingPoiSheet( + centerLat = lastLat ?: mapLibreMap?.cameraPosition?.target?.latitude, + centerLon = lastLng ?: mapLibreMap?.cameraPosition?.target?.longitude, + routingEndpoint = mapStyleSettings.routingEndpoint, + onRoutingEndpointChange = { url -> + scope.launch { runCatching { mapStyleStore.setRoutingEndpoint(url) } } + }, + onAddPoiAsWaypoint = { poi -> + val newWp = Waypoint( + id = java.util.UUID.randomUUID().toString(), + name = poi.displayName.take(60), + latitude = poi.latitude, + longitude = poi.longitude, + radiusMeters = 30f, + soundUri = "", + tourName = selectedTour, + isActive = true + ) + viewModel.upsert(newWp) + }, + onAddPoiToStoryboard = { poi -> + val newWp = Waypoint( + id = java.util.UUID.randomUUID().toString(), + name = poi.displayName.take(60), + latitude = poi.latitude, + longitude = poi.longitude, + radiusMeters = 30f, + soundUri = "", + tourName = selectedTour, + isActive = true + ) + viewModel.upsert(newWp) + showStoryboardSheet = true + }, + onComputeRoute = { + val endpoint = mapStyleSettings.routingEndpoint.trim() + if (endpoint.isBlank()) { + routeStatus = routingPoiCtx.getString(R.string.routing_endpoint_not_configured) + return@RoutingPoiSheet + } + val pts = waypoints.map { it.latitude to it.longitude } + if (pts.size < 2) { + routeStatus = routingPoiCtx.getString(R.string.routing_compute_no_points) + return@RoutingPoiSheet + } + routeLoading = true + routeStatus = routingPoiCtx.getString(R.string.routing_compute_loading) + scope.launch { + runCatching { + de.waypointaudio.util.OsrmRoutingClient.route(routingPoiCtx, endpoint, pts) + }.onSuccess { result -> + routeLine = result.polyline.map { (lat, lon) -> LatLng(lat, lon) } + routeStatus = routingPoiCtx.getString( + R.string.routing_compute_success, + de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), + de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) + ) + }.onFailure { + routeLine = emptyList() + routeStatus = routingPoiCtx.getString(R.string.routing_compute_error) + } + routeLoading = false + } + }, + routeStatus = routeStatus, + onDismiss = { showRoutingInfo = false } + ) + } + + if (showSearchDialog) { + PlaceSearchDialog( + onDismiss = { showSearchDialog = false }, + onResultSelected = { result -> + val gp = LatLng(result.latitude, result.longitude) + searchPin = gp + mapLibreMap?.let { ml -> + ml.animateCamera(CameraUpdateFactory.newLatLngZoom(gp, 15.0)) + } + followLocation = false + showSearchDialog = false + } + ) + } + + showDraftPointMenu?.let { sel -> + val idx = sel.index + val draft = activeDraft + val pt = if (draft != null) draft.points.getOrNull(idx) else visibleTrack.getOrNull(idx) + AlertDialog( + onDismissRequest = { showDraftPointMenu = null }, + title = { Text(stringResource(R.string.explorer_track_point_title, idx + 1)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (pt != null) { + Text( + "%.6f, %.6f".format(java.util.Locale.US, pt.latitude, pt.longitude), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + } + if (draft != null) { + val isMarked = idx in draft.markedIndexes + Button( + onClick = { + val newMarked = if (isMarked) draft.markedIndexes - idx + else (draft.markedIndexes + idx).distinct().sorted() + viewModel.saveDraft(draft.copy(markedIndexes = newMarked)) + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isMarked) MaterialTheme.colorScheme.tertiary + else MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Bookmarks, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + if (isMarked) stringResource(R.string.draft_point_unmark) + else stringResource(R.string.draft_point_mark) + ) + } + Button( + onClick = { + selectedTrackIndex = idx + tapMode = TapModeMl.MoveTrackPoint(idx) + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_tap)) + } + Button( + onClick = { + val ml = mapLibreMap + if (ml != null) { + val center = ml.cameraPosition.target + val pts = draft.points.toMutableList() + if (idx in pts.indices && center != null) { + pts[idx] = pts[idx].copy( + latitude = center.latitude, + longitude = center.longitude + ) + viewModel.saveDraft(draft.copy(points = pts)) + } + } + showDraftPointMenu = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_action_move_to_center)) + } + Button( + onClick = { + val pts = draft.points.toMutableList() + if (idx in pts.indices) { + pts.removeAt(idx) + val newMarked = draft.markedIndexes + .filter { it != idx } + .map { if (it > idx) it - 1 else it } + viewModel.saveDraft(draft.copy(points = pts, markedIndexes = newMarked)) + } + showDraftPointMenu = null + }, + enabled = !isRecording, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.delete)) + } + } else { + Text( + stringResource(R.string.explorer_track_point_no_draft_hint), + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton(onClick = { showDraftPointMenu = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showDraftsSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showDraftsSheet = false }, + sheetState = sheetState + ) { + DraftsPanelMl( + drafts = drafts, + activeDraftId = activeDraftId, + isRecording = isRecording, + onActivate = { id -> + activeDraftId = if (activeDraftId == id) null else id + val ml = mapLibreMap + val first = drafts.firstOrNull { it.id == id }?.points?.firstOrNull() + if (ml != null && first != null) { + ml.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(first.latitude, first.longitude), + 15.0 + ) + ) + followLocation = false + } + }, + onOpenFullEditor = { id -> onOpenDraft(id) }, + onDelete = { id -> + viewModel.deleteDraft(id) + if (activeDraftId == id) activeDraftId = null + }, + onImport = { id -> + val d = drafts.firstOrNull { it.id == id } ?: return@DraftsPanelMl + viewModel.importDraftAsWaypoints(d, selectedTour) + }, + onClose = { showDraftsSheet = false } + ) + } + } + + if (showStoryboardSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showStoryboardSheet = false }, + sheetState = sheetState + ) { + StoryboardPanelMl( + tourWaypoints = waypoints, + currentTour = selectedTour, + onSaveAsTour = { targetTour, orderedIds -> + viewModel.applyStoryboardOrder(targetTour, orderedIds) + showStoryboardSheet = false + }, + onClose = { showStoryboardSheet = false } + ) + } + } + + // ─── Scaffold ──────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + // v2.5.6 — Explorer TopBar im v2.5.x-Stil: + // Dezente Surface-Fläche statt knalligem Primary-Teal, primary nur + // als Akzent in Icons/Aktionen. Bessere Harmonie mit HomeScreen und + // den neuen Tour-/Audio-Cards. + TopAppBar( + title = { + if (currentMode != null && onModeChange != null) { + MapModeSegmentedHeader( + current = currentMode, + onChange = onModeChange, + subtitle = selectedTour, + contentColor = MaterialTheme.colorScheme.onSurface + ) + } else { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Explore, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.explorer_title)) + } + Text( + text = selectedTour, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + actions = { + IconButton(onClick = { showSearchDialog = true }) { + Icon( + Icons.Filled.Search, + contentDescription = stringResource(R.string.search_place_action) + ) + } + IconButton(onClick = { followLocation = !followLocation }) { + Icon( + if (followLocation) Icons.Filled.GpsFixed else Icons.Filled.GpsOff, + contentDescription = if (followLocation) + stringResource(R.string.explorer_follow_on) + else + stringResource(R.string.explorer_follow_off), + tint = if (followLocation) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { + tapMode = if (tapMode is TapModeMl.PlaceNew) TapModeMl.None else TapModeMl.PlaceNew + }) { + Icon( + Icons.Filled.TouchApp, + contentDescription = stringResource(R.string.explorer_action_tap_place), + tint = if (tapMode is TapModeMl.PlaceNew) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary + ) + ) + }, + floatingActionButton = { + // v2.5.6 — FABs harmonisiert: ruhige, glasige Surfaces statt + // knalliges Primary-Teal; Tonale Erhöhung sorgt für sichtbaren + // Kontrast gegenüber der Karte ohne grelle Akzente. + // v2.5.40 — Toolbar-Fix: Alle FABs in einer Column mit + // genügend Abstand; kein Überlappen mit MapLayerFab (TopEnd). + // Der Compass-Button ist neu (Bearing auf 0 zurücksetzen). + val neutralFabContainer = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + val neutralFabContent = MaterialTheme.colorScheme.primary + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(bottom = 8.dp) + ) { + // v2.5.40 — Kompass/Nordausrichten + SmallFloatingActionButton( + onClick = { + val ml = mapLibreMap ?: return@SmallFloatingActionButton + val pos = ml.cameraPosition + ml.animateCamera( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder(pos).bearing(0.0).build() + ) + ) + }, + containerColor = neutralFabContainer, + contentColor = neutralFabContent + ) { + Icon( + Icons.Filled.Navigation, + contentDescription = "Nach Norden ausrichten" + ) + } + SmallFloatingActionButton( + onClick = { showDraftsSheet = true }, + containerColor = neutralFabContainer, + contentColor = neutralFabContent + ) { + Icon(Icons.Filled.Timeline, contentDescription = stringResource(R.string.explorer_drafts_open)) + } + SmallFloatingActionButton( + onClick = { showStoryboardSheet = true }, + containerColor = neutralFabContainer, + contentColor = neutralFabContent + ) { + 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. + SmallFloatingActionButton( + onClick = { + manualRouteMode = !manualRouteMode + tapMode = if (manualRouteMode) TapModeMl.DrawRoutePoint + else TapModeMl.None + }, + containerColor = if (manualRouteMode) + MaterialTheme.colorScheme.tertiaryContainer + else + neutralFabContainer, + contentColor = if (manualRouteMode) + MaterialTheme.colorScheme.onTertiaryContainer + else + neutralFabContent + ) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(R.string.explorer_mode_draw_route) + ) + } + if (isRecording) { + ExtendedFloatingActionButton( + onClick = { viewModel.stopTrackRecording() }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + icon = { Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.map_track_stop)) }, + text = { Text(stringResource(R.string.map_track_recording)) } + ) + } else { + SmallFloatingActionButton( + onClick = { viewModel.startTrackRecording() }, + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) { + Icon( + Icons.Filled.FiberManualRecord, + contentDescription = stringResource(R.string.map_track_start) + ) + } + } + FloatingActionButton( + onClick = { + val ml = mapLibreMap + val center = ml?.cameraPosition?.target + val pre = if (center != null) Pair(center.latitude, center.longitude) + else if (lastLat != null && lastLng != null) Pair(lastLat!!, lastLng!!) + else null + editDialogWaypoint = null + editDialogPrefill = pre + showEditDialog = true + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Icon( + Icons.Filled.AddLocation, + contentDescription = stringResource(R.string.explorer_add_waypoint) + ) + } + SmallFloatingActionButton( + onClick = { + val ml = mapLibreMap ?: return@SmallFloatingActionButton + val lat = lastLat + val lng = lastLng + if (lat != null && lng != null) { + ml.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(lat, lng), 17.0)) + followLocation = true + } else if (waypoints.isNotEmpty()) { + val first = waypoints.first() + ml.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(first.latitude, first.longitude), 15.0 + ) + ) + } + }, + containerColor = neutralFabContainer, + contentColor = neutralFabContent + ) { + Icon( + Icons.Filled.MyLocation, + contentDescription = stringResource(R.string.explorer_my_location) + ) + } + } + } + ) { inner -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(inner) + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val initialCenter = if (waypoints.isNotEmpty()) { + LatLng(waypoints.first().latitude, waypoints.first().longitude) + } else { + LatLng(51.1657, 10.4515) + } + MapView(ctx).also { mv -> + mv.onCreate(null) + mv.getMapAsync { ml -> + mapLibreMap = ml + ml.cameraPosition = CameraPosition.Builder() + .target(initialCenter) + .zoom(13.0) + .build() + ml.uiSettings.isLogoEnabled = false + ml.uiSettings.isAttributionEnabled = false + ml.uiSettings.setCompassEnabled(true) + // v2.5.37 — Initialer Stil beim Map-Aufbau. + // Vektor-Stile via OpenFreeMap-URI laden. + val initialStyleBuilder = when (layerState.baseStyle) { + MapBaseStyle.DARK -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/dark") + MapBaseStyle.OUTDOOR, MapBaseStyle.TOPO -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/liberty") + MapBaseStyle.CITY_3D, MapBaseStyle.TERRAIN_3D, MapBaseStyle.PERSPECTIVE -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron") + MapBaseStyle.CLASSIC, MapBaseStyle.SATELLITE -> Style.Builder().fromJson(buildClassicOsmStyleJson()) + else -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron") + } + 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) { + try { addBuildingExtrusionIfAvailable(loadedStyle) } catch (_: Exception) {} + val pos2 = ml.cameraPosition + ml.cameraPosition = CameraPosition.Builder(pos2) + .tilt(mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0)) + .build() + } + styleReady = true + } + // Klick-Handler für Wegpunkt-/Track-Marker. + ml.setOnMarkerClickListener { m -> + val wp = waypointMarkerIdToWaypoint[m.id] + if (wp != null) { + actionMenuWaypoint = wp + return@setOnMarkerClickListener true + } + val ti = trackMarkerIdToIndex[m.id] + if (ti != null) { + // v2.5.33 — Phantom-Track-Fix: Nur öffnen, wenn ein + // gültiger aktiver Draft oder eine laufende Aufzeichnung + // existiert. Ohne gültigen Kontext (verwaiste Marker) wird + // der Tap still ignoriert, statt einen irreführenden + // Bearbeitungsdialog mit 'Aktiviere zuerst einen Draft' + // zu zeigen. + if (activeDraft != null || isRecording) { + showDraftPointMenu = DraftPointSelectionMl(ti) + } + return@setOnMarkerClickListener true + } + // v2.5.3 — Klick auf einen Routenpunkt der manuellen Route + // öffnet einen einfachen Punkt-Dialog (Verschieben/Löschen). + val ri = manualRouteMarkerIdToIndex[m.id] + if (ri != null) { + selectedRoutePointIndex = ri + showRoutePointDialog = true + return@setOnMarkerClickListener true + } + // v2.5.40 — Klick auf Explorer-POI-Marker + val epoi = explorerPoiMarkerIdToResult[m.id] + if (epoi != null) { + selectedExplorerPoi = epoi + return@setOnMarkerClickListener true + } + false + } + ml.addOnMapClickListener { p -> + when (val m = tapMode) { + is TapModeMl.PlaceNew -> { + editDialogWaypoint = null + editDialogPrefill = Pair(p.latitude, p.longitude) + showEditDialog = true + tapMode = TapModeMl.None + true + } + is TapModeMl.MoveWaypoint -> { + val target = allWaypoints.firstOrNull { it.id == m.id } + if (target != null) { + viewModel.upsert( + target.copy(latitude = p.latitude, longitude = p.longitude) + ) + } + tapMode = TapModeMl.None + true + } + is TapModeMl.MoveTrackPoint -> { + val draft = activeDraft + if (draft != null) { + val pts = draft.points.toMutableList() + if (m.index in pts.indices) { + pts[m.index] = pts[m.index].copy( + latitude = p.latitude, + longitude = p.longitude + ) + viewModel.saveDraft(draft.copy(points = pts)) + } + } + tapMode = TapModeMl.None + selectedTrackIndex = null + true + } + is TapModeMl.DrawRoutePoint -> { + // v2.5.3 — Stiller Routenpunkt: hängt nur an + // die Polyline an. Kein Audio, kein Wegpunkt, + // keine Clip-Länge. + manualRoutePoints = manualRoutePoints + LatLng( + p.latitude, p.longitude + ) + // Im Modus bleiben, damit weitere Punkte gesetzt + // werden können — Verlassen via Cancel-Chip oder + // Mode-Toggle. + true + } + is TapModeMl.MoveRoutePoint -> { + val pts = manualRoutePoints.toMutableList() + if (m.index in pts.indices) { + pts[m.index] = LatLng(p.latitude, p.longitude) + manualRoutePoints = pts + } + tapMode = TapModeMl.None + selectedRoutePointIndex = null + true + } + TapModeMl.None -> false + } + } + ml.addOnMapLongClickListener { p -> + // v2.5.3 — Im manuellen Routemodus dient LongPress + // ebenfalls dem Setzen eines Routenpunkts und nicht + // dem Anlegen eines Audio-Wegpunkts. + if (manualRouteMode || tapMode is TapModeMl.DrawRoutePoint) { + manualRoutePoints = manualRoutePoints + LatLng( + p.latitude, p.longitude + ) + return@addOnMapLongClickListener true + } + editDialogWaypoint = null + editDialogPrefill = Pair(p.latitude, p.longitude) + showEditDialog = true + true + } + // Folow-disable bei manueller Bewegung: + ml.addOnMoveListener(object : org.maplibre.android.maps.MapLibreMap.OnMoveListener { + override fun onMoveBegin(detector: org.maplibre.android.gestures.MoveGestureDetector) { + 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 + } + } + } + } + } + override fun onMove(detector: org.maplibre.android.gestures.MoveGestureDetector) {} + override fun onMoveEnd(detector: org.maplibre.android.gestures.MoveGestureDetector) {} + }) + // v2.5.41 — Zoom-Tracking für zoom-abhängige POI-Label-Anzeige + ml.addOnCameraIdleListener { + currentMapZoom = ml.cameraPosition.zoom + } + } + mv.onStart() + mv.onResume() + mapView = mv + } + }, + update = { /* state already wired via mapLibreMap */ } + ) + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), + shadowElevation = 4.dp, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp, start = 12.dp, end = 12.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val tapHint: String? = when (tapMode) { + is TapModeMl.PlaceNew -> stringResource(R.string.explorer_tap_place_active) + is TapModeMl.MoveWaypoint -> stringResource(R.string.explorer_tap_move_active) + is TapModeMl.MoveTrackPoint -> stringResource(R.string.explorer_tap_move_track_active) + is TapModeMl.DrawRoutePoint -> + stringResource(R.string.explorer_mode_draw_route_active) + is TapModeMl.MoveRoutePoint -> + stringResource(R.string.explorer_route_point_move_active) + TapModeMl.None -> null + } + if (tapHint != null) { + Icon( + Icons.Filled.TouchApp, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + tapHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val statusIcon = when { + isRecording -> Icons.Filled.FiberManualRecord + activeDraft != null -> Icons.Filled.Timeline + else -> Icons.Filled.Place + } + val statusText = when { + isRecording -> stringResource( + R.string.explorer_status_recording_compact, currentTrack.size + ) + activeDraft != null -> stringResource( + R.string.explorer_status_draft_compact, + activeDraft.markedCount, activeDraft.pointCount + ) + else -> stringResource( + R.string.explorer_status_compact, waypoints.size + ) + } + val statusTint = when { + isRecording -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + Icon( + statusIcon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = statusTint + ) + Text( + statusText, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val acc = lastAccuracyM + if (acc != null) { + Text( + "· ${stringResource(R.string.explorer_accuracy_meters, acc.toInt())}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } + } + } + } + + AnimatedVisibility( + visible = tapMode !is TapModeMl.None, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 40.dp), + enter = fadeIn() + slideInVertically(initialOffsetY = { it }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it }) + ) { + FilterChip( + selected = true, + onClick = { + // v2.5.3 — Cancel verlässt auch den manualRouteMode, + // damit der violette FAB visuell wieder deaktiviert ist. + if (tapMode is TapModeMl.DrawRoutePoint) manualRouteMode = false + tapMode = TapModeMl.None + }, + label = { + Text(stringResource(R.string.explorer_cancel_tap)) + } + ) + } + + MapLayerFab( + state = layerState, + onStateChange = { layerState = it }, + customSatelliteUrl = mapStyleSettings.customSatelliteUrl, + onCustomSatelliteUrlChange = { url -> + scope.launch { + runCatching { mapStyleStore.setCustomSatelliteUrl(url) } + } + }, + customTopoUrl = mapStyleSettings.customTopoUrl, + onCustomTopoUrlChange = { url -> + scope.launch { + runCatching { mapStyleStore.setCustomTopoUrl(url) } + } + }, + customTerrainDemUrl = mapStyleSettings.customTerrainDemUrl, + onCustomTerrainDemUrlChange = { url -> + scope.launch { + runCatching { mapStyleStore.setCustomTerrainDemUrl(url) } + } + }, + perspectiveTilt = mapStyleSettings.perspectiveTilt, + onPerspectiveTiltChange = { tilt -> + scope.launch { + runCatching { mapStyleStore.setPerspectiveTilt(tilt) } + } + }, + onShowRoutingInfo = { showRoutingInfo = true }, + onShowRoutePlanner = { showRoutePlanner = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + ) + + // Renderer-Badge: zeigt deutlich, dass MapLibre als aktiver Renderer läuft. + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.92f), + shadowElevation = 2.dp, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 6.dp, bottom = 6.dp) + ) { + Text( + text = stringResource(R.string.explorer_renderer_maplibre_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + + // v2.5.38 — Kompaktes Attribution-Badge, ersetzt altes OsmAttributionBadge. + // Pflichtangabe ist stets sichtbar, aber schöner und nicht überlagernd. + MapCompactAttributionBadge( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 6.dp) + ) + + // v2.5.41 — POI-Legende: einklappbare Kategorie-Übersicht + // Nur anzeigen wenn mindestens ein POI-Layer aktiv ist + val anyPoiLayerActive = layerState.poiLayers.let { + it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure + } + if (anyPoiLayerActive) { + ExplorerPoiLegend( + poiLayers = layerState.poiLayers, + expanded = poiLegendExpanded, + onToggle = { poiLegendExpanded = !poiLegendExpanded }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 30.dp) + ) + } + + // v2.5.40 — Explorer-POI-Lade-Button (mittig unten, über Attribution) + if (layerState.poiLayers.let { + it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure + }) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.95f), + shadowElevation = 3.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (explorerPoiLoading) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } else { + Icon( + Icons.Filled.Explore, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Text( + text = if (explorerPoiLoading) "POIs werden geladen …" + else "POIs im Kartenbereich laden", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.clickable(enabled = !explorerPoiLoading) { + val ml = mapLibreMap ?: return@clickable + val bounds = ml.projection.visibleRegion.latLngBounds + val bbox = LatLngBbox( + south = bounds.southWest.latitude, + west = bounds.southWest.longitude, + north = bounds.northEast.latitude, + east = bounds.northEast.longitude + ) + val cats = buildSet { + if (layerState.poiLayers.showSights) add(PoiCategory.SIGHTS) + if (layerState.poiLayers.showGastronomy) add(PoiCategory.GASTRONOMY) + if (layerState.poiLayers.showMobility) add(PoiCategory.MOBILITY) + if (layerState.poiLayers.showInfrastructure) add(PoiCategory.INFRASTRUCTURE) + } + explorerPoiLoading = true + scope.launch { + try { + val results = ExplorerPoiClient.loadPois( + context = context, + bbox = bbox, + categories = cats, + onRadiusClamped = { + explorerPoiSnackbar = "Kartenausschnitt zu groß – Abfrage auf max. 5 km Radius begrenzt." + } + ) + val total = results.values.sumOf { it.size } + explorerPoiResults = results + // v2.5.44 — POI-Zusammenfassung im Snackbar + explorerPoiSnackbar = if (total == 0) { + "Keine POIs in diesem Bereich gefunden." + } else { + ExplorerPoiClient.summarize(results) + } + } catch (e: Exception) { + explorerPoiSnackbar = "POI-Laden fehlgeschlagen: ${e.message?.take(60) ?: "Netzwerkfehler"}" + } finally { + explorerPoiLoading = false + } + } + } + ) + } + } + } + + // v2.5.40 — Snackbar-Host für Explorer-POI-Meldungen + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 80.dp) + ) + } + } + + // v2.5.40 — Explorer-POI-Detail-Sheet + selectedExplorerPoi?.let { poi -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { selectedExplorerPoi = null }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Titel + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Place, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text(poi.displayName, style = MaterialTheme.typography.titleMedium) + } + // Kategorie + Entfernung + val distText = if (lastLat != null && lastLng != null) { + val d = ExplorerPoiClient.haversineM(lastLat!!, lastLng!!, poi.latitude, poi.longitude) + if (d < 1000) "%.0f m entfernt".format(d) + else "%.1f km entfernt".format(d / 1000.0) + } else null + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + poi.categoryLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + if (distText != null) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + distText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + } + } + // Koordinaten + Text( + "%.5f, %.5f".format(poi.latitude, poi.longitude), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + // OSM-Tags (kompakt, max. 6) + if (poi.rawTags.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text( + "OSM-Tags", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + poi.rawTags.entries.take(6).forEach { (k, v) -> + Text( + "$k = $v", + style = MaterialTheme.typography.bodySmall + ) + } + } + // Attribution + Text( + "POI-Daten: OpenStreetMap/Overpass (ODbL)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f) + ) + Spacer(Modifier.height(4.dp)) + // Aktionen + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + // Als Wegpunkt anlegen + val newWp = Waypoint( + latitude = poi.latitude, + longitude = poi.longitude, + name = poi.displayName, + tourName = selectedTour, + ) + viewModel.upsert(newWp) + selectedExplorerPoi = null + explorerPoiSnackbar = "\"${poi.displayName}\" als Wegpunkt angelegt." + }) { + Icon(Icons.Filled.AddLocation, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Als Wegpunkt") + } + TextButton(onClick = { selectedExplorerPoi = null }) { + Text("Schließen") + } + } + } + } + } + + DisposableEffect(Unit) { + onDispose { + runCatching { + mapView?.onPause() + mapView?.onStop() + mapView?.onDestroy() + } + } + } +} + +private data class DraftPointSelectionMl(val index: Int) + +private sealed interface TapModeMl { + data object None : TapModeMl + data object PlaceNew : TapModeMl + data class MoveWaypoint(val id: String) : TapModeMl + data class MoveTrackPoint(val index: Int) : TapModeMl + /** v2.5.3 — Manueller Routemodus: Tippen setzt einen stillen Routenpunkt. */ + data object DrawRoutePoint : TapModeMl + /** v2.5.3 — Verschieben eines Routenpunkts an Index [index] der Polyline. */ + data class MoveRoutePoint(val index: Int) : TapModeMl +} + +// ─────────────────────────────────────────────────────────────────────────── +// Overlay-Renderer (MapLibre Annotation API) +// ─────────────────────────────────────────────────────────────────────────── + +private fun clearAllAnnotationsCaches( + waypointMarkers: MutableList, + waypointMarkerIdToWaypoint: MutableMap, + waypointRadiusPolygons: MutableList, + trackMarkers: MutableList, + trackMarkerIdToIndex: MutableMap, + trackPolylines: MutableList, + routePolylines: MutableList, + audioLinePolylines: MutableList, + manualRoutePolylines: MutableList? = null, + manualRouteMarkers: MutableList? = null, + manualRouteMarkerIdToIndex: MutableMap? = null, +) { + waypointMarkers.clear() + waypointMarkerIdToWaypoint.clear() + waypointRadiusPolygons.clear() + trackMarkers.clear() + trackMarkerIdToIndex.clear() + trackPolylines.clear() + routePolylines.clear() + audioLinePolylines.clear() + manualRoutePolylines?.clear() + manualRouteMarkers?.clear() + manualRouteMarkerIdToIndex?.clear() +} + +@Suppress("LongParameterList") +private fun drawExplorerOverlaysMapLibre( + ml: MapLibreMap, + context: android.content.Context, + waypoints: List, + track: List, + markedIndexes: List, + selectedTrackIndex: Int?, + primaryArgb: Int, + trackArgb: Int, + showRadius: Boolean, + showAudioLength: Boolean, + showAudioLines: Boolean, + audioDurationsMs: Map, + selectedId: String?, + searchPin: LatLng?, + clipLineGeometry: List?, + waypointMarkers: MutableList, + waypointMarkerIdToWaypoint: MutableMap, + waypointRadiusPolygons: MutableList, + trackMarkers: MutableList, + trackMarkerIdToIndex: MutableMap, + trackPolylines: MutableList, + audioLinePolylines: MutableList, + searchPinMarkerRef: (Marker?) -> Unit, + currentSearchPinMarker: Marker?, +) { + // Vorhandene eigene Annotations entfernen. + for (m in waypointMarkers) runCatching { ml.removeMarker(m) } + waypointMarkers.clear() + waypointMarkerIdToWaypoint.clear() + for (p in waypointRadiusPolygons) runCatching { ml.removePolygon(p) } + waypointRadiusPolygons.clear() + for (m in trackMarkers) runCatching { ml.removeMarker(m) } + trackMarkers.clear() + trackMarkerIdToIndex.clear() + for (l in trackPolylines) runCatching { ml.removePolyline(l) } + trackPolylines.clear() + for (l in audioLinePolylines) runCatching { ml.removePolyline(l) } + audioLinePolylines.clear() + currentSearchPinMarker?.let { runCatching { ml.removeMarker(it) } } + searchPinMarkerRef(null) + + val density = context.resources.displayMetrics.density + val iconFactory = IconFactory.getInstance(context) + + // Track-Polyline (Halo + Linie). + 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 + } + + if (track.isNotEmpty()) { + val markedSet = markedIndexes.toSet() + track.forEachIndexed { idx, p -> + val gp = LatLng(p.latitude, p.longitude) + val isMarked = idx in markedSet + val isSelected = idx == selectedTrackIndex + val sizePx = when { + isSelected -> 80 + isMarked -> 64 + else -> 44 + } + val color = when { + isSelected -> 0xFFFF5252.toInt() + isMarked -> 0xFFFFAB00.toInt() + else -> trackArgb + } + val bm = trackPointBitmap(sizePx, color, isSelected) + val marker = ml.addMarker( + MarkerOptions() + .position(gp) + .icon(iconFactory.fromBitmap(bm)) + ) + trackMarkers += marker + trackMarkerIdToIndex[marker.id] = idx + } + } + + // v2.4.5 — Clip-Längenlinien NUR entlang bekannter Geometrie (Track/Route), + // sonst gar nicht. Pro Waypoint wird ein Abschnitt der Länge proportional + // zur Audiodauer auf der Geometrie gezeichnet. + // + // Änderung gegenüber v2.4.4: Der Waypoint markiert nicht mehr die MITTE, + // sondern den ANFANG des Clip-Abschnitts. Der Abschnitt erstreckt sich + // von der Projektionsposition des Waypoints in Richtung Routenende + // (nach vorne entlang der Route/Track-Richtung). Damit "wirkt" der + // Waypoint visuell als Trigger-Start des Clips, nicht als dessen Mittelpunkt. + // + // Sonderfall: Wenn der Waypoint nahe dem Ende der Geometrie liegt und + // nicht mehr genug Vorwärtsweg verbleibt, wird der Abschnitt nach hinten + // verlängert, damit er sichtbar bleibt — eine Fantasielinie über das + // Geometrieende hinaus zeichnen wir bewusst nicht. + if (showAudioLines && waypoints.isNotEmpty() && clipLineGeometry != null && clipLineGeometry.size >= 2) { + val cumulative = cumulativeMeters(clipLineGeometry) + val totalLen = cumulative.last() + for (wp in waypoints) { + val durationMs = audioDurationsMs[wp.soundUri] ?: 0L + if (durationMs <= 0L) continue + val sec = durationMs / 1000.0 + val segLen = (sec * 5.0).coerceIn(15.0, 250.0).coerceAtMost(totalLen) + // anchor auf der Geometrie: nächster Punkt zu (wp.lat, wp.lng) + val anchorM = projectOntoGeometry(clipLineGeometry, cumulative, wp.latitude, wp.longitude) + // v2.4.5: Start AB Waypoint nach vorne entlang der Geometrie. + var startM = anchorM + var endM = startM + segLen + if (endM > totalLen) { + // Nahe dem Geometrieende: Abschnitt zurückziehen, damit er + // ganz auf der Geometrie liegt (keine Linie ins Nichts). + endM = totalLen + startM = (endM - segLen).coerceAtLeast(0.0) + } + val segPoints = sliceGeometry(clipLineGeometry, cumulative, startM, endM) + if (segPoints.size < 2) continue + val bucket = WaypointAudioDuration.classify(durationMs) + val color = when (bucket) { + de.waypointaudio.util.AudioLengthBucket.SHORT -> 0xFF66BB6A.toInt() + de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 0xFFFFA726.toInt() + de.waypointaudio.util.AudioLengthBucket.LONG -> 0xFFE53935.toInt() + else -> primaryArgb + } + val w = when (bucket) { + de.waypointaudio.util.AudioLengthBucket.LONG -> 7f + 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 + } + } + + 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 + } else 0L + val bm = if (durationMs > 0L) { + waypointDotWithRing(primaryArgb, density, isSelected, durationMs) + } else { + waypointDotPlain(primaryArgb, density, isSelected) + } + val marker = ml.addMarker( + MarkerOptions() + .position(gp) + .title(wp.name.ifBlank { "Wegpunkt" }) + .icon(iconFactory.fromBitmap(bm)) + ) + 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) + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Bitmap-Helfer (lokal, damit unabhängig vom osmdroid-Renderer) +// ─────────────────────────────────────────────────────────────────────────── + +private fun explorerMlLocationPuckBitmap(context: android.content.Context): Bitmap? { + return runCatching { + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck) + ?: return@runCatching null + val size = (48 * context.resources.displayMetrics.density).toInt() + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + }.getOrNull() +} + +private fun trackPointBitmap(sizePx: Int, argb: Int, selected: Boolean): Bitmap { + val size = sizePx.coerceAtLeast(16) + val bm = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val cx = size / 2f; val cy = size / 2f + val outerR = size / 2f - 2f + val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + alpha = 220 + } + canvas.drawCircle(cx, cy, outerR, halo) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = argb + } + canvas.drawCircle(cx, cy, outerR - size * 0.18f, fill) + if (selected) { + val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = 0xFF000000.toInt() + strokeWidth = (size * 0.08f).coerceAtLeast(3f) + } + canvas.drawCircle(cx, cy, outerR - size * 0.20f, ring) + } + return bm +} + +/** + * v2.4.6 — Baut aus dem aktuellen Routenplaner-Zustand eine [TourRoute]. + * + * Bevorzugt die aktuell berechnete OSRM-Routenlinie; fällt auf die Planlinie + * zurück, wenn keine berechnete Route vorhanden ist. Gibt `null` zurück, + * wenn überhaupt keine nutzbare Geometrie existiert — die UI verhindert + * dann das Speichern. + */ +private fun buildTourRouteFromState( + plan: RoutePlan, + routeLine: List, + routePlanline: List, + distanceMeters: Double?, + durationSeconds: Double?, + endpoint: String, + planlineSourceLabel: String, +): de.waypointaudio.data.TourRoute? { + val useComputed = routeLine.size >= 2 + val poly = when { + useComputed -> routeLine + routePlanline.size >= 2 -> routePlanline + else -> return null + } + val routePoints = poly.map { + de.waypointaudio.data.TourRoutePoint(lat = it.latitude, lon = it.longitude) + } + fun toStop(rs: RouteStop?): de.waypointaudio.data.TourRouteStop? = + rs?.let { + de.waypointaudio.data.TourRouteStop( + latitude = it.latitude, + longitude = it.longitude, + label = it.label, + sourceHint = it.source.name + ) + } + val viaStops = plan.via.map { + de.waypointaudio.data.TourRouteStop( + latitude = it.latitude, + longitude = it.longitude, + label = it.label, + sourceHint = it.source.name + ) + } + return de.waypointaudio.data.TourRoute( + start = toStop(plan.start), + destination = toStop(plan.destination), + via = viaStops, + polyline = routePoints, + // v2.5.2 — Profil aus dem aktuellen Plan übernehmen, damit gespeicherte + // Tourrouten später wissen, ob sie Auto/Fahrrad/Fußgänger waren. + profile = plan.profile.osrmKey, + source = if (useComputed) de.waypointaudio.data.RouteSourceKind.OSRM + else de.waypointaudio.data.RouteSourceKind.PLANLINE, + endpoint = if (useComputed) endpoint else planlineSourceLabel, + distanceMeters = if (useComputed) distanceMeters else null, + durationSeconds = if (useComputed) durationSeconds else null + ) +} + +/** + * v2.5.2 — Liefert die String-Ressource für das angegebene Routingprofil. + * Wird sowohl für UI-Labels als auch für Fehlermeldungen benötigt. + */ +@androidx.annotation.StringRes +private fun profileLabelRes(profile: RoutingProfile): Int = when (profile) { + RoutingProfile.DRIVING -> R.string.route_planner_profile_driving + RoutingProfile.CYCLING -> R.string.route_planner_profile_cycling + RoutingProfile.WALKING -> R.string.route_planner_profile_walking +} + +/** Kategorie eines Routen-Stopps — bestimmt Farbe/Form auf der Karte. */ +private enum class RouteStopKind { START, VIA, DESTINATION } + +/** + * v2.4.2 — Erzeugt einen unterscheidbaren Marker für Start/Via/Ziel der Route. + * + * Start : grüne Pin-Form mit Innenring + * Via : orange Pin mit Nummern-Label (Indizes 1..n) + * Ziel : roter Pin mit „Z“-Glyph + * + * Alle Marker sind kontrastreich und auf hell/dunkel/topo/satellit gut sichtbar. + */ +private fun routeStopBitmap( + kind: RouteStopKind, + context: android.content.Context, + label: String? = null, +): Bitmap { + val density = context.resources.displayMetrics.density + val size = (44f * density).toInt().coerceAtLeast(36) + val bm = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val cx = size / 2f + val cy = size / 2f + val r = size * 0.36f + val color = when (kind) { + RouteStopKind.START -> 0xFF2E7D32.toInt() + RouteStopKind.VIA -> 0xFFFB8C00.toInt() + RouteStopKind.DESTINATION -> 0xFFC62828.toInt() + } + // Halo (weiß) + val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = 0xFFFFFFFF.toInt() + } + canvas.drawCircle(cx, cy, r + 4f * density, halo) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = color + } + canvas.drawCircle(cx, cy, r, fill) + val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f * density + this.color = 0xFF202020.toInt() + } + canvas.drawCircle(cx, cy, r, ring) + // Glyph + val glyph = when (kind) { + RouteStopKind.START -> "S" + RouteStopKind.VIA -> label ?: "•" + RouteStopKind.DESTINATION -> "Z" + } + val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = 0xFFFFFFFF.toInt() + textSize = r * 1.05f + textAlign = Paint.Align.CENTER + isFakeBoldText = true + } + val baseline = cy - (text.descent() + text.ascent()) / 2f + canvas.drawText(glyph, cx, baseline, text) + return bm +} + +private fun waypointDotPlain( + fillArgb: Int, + density: Float, + isSelected: Boolean +): Bitmap { + val touchDp = if (isSelected) 48f else 38f + val sizePx = (touchDp * density).toInt().coerceAtLeast(32) + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val cx = sizePx / 2f + val cy = sizePx / 2f + val visibleRadius = (if (isSelected) 14f else 10f) * density + val halo = (if (isSelected) 4f else 3f) * density + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.style = Paint.Style.FILL + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius + halo, paint) + paint.color = fillArgb + canvas.drawCircle(cx, cy, visibleRadius, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density + paint.color = 0xFF202020.toInt() + canvas.drawCircle(cx, cy, visibleRadius, paint) + return bmp +} + +private fun waypointDotWithRing( + fillArgb: Int, + density: Float, + isSelected: Boolean, + durationMs: Long +): Bitmap { + val touchDp = if (isSelected) 60f else 50f + val sizePx = (touchDp * density).toInt().coerceAtLeast(48) + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val cx = sizePx / 2f + val cy = sizePx / 2f + val visibleRadius = (if (isSelected) 14f else 10f) * density + val halo = (if (isSelected) 4f else 3f) * density + val ringInner = visibleRadius + halo + 2f * density + val ringOuter = ringInner + 4f * density + val ringMid = (ringInner + ringOuter) / 2f + val ringStroke = (ringOuter - ringInner) + + val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = ringStroke + color = 0x33000000.toInt() + } + canvas.drawCircle(cx, cy, ringMid, track) + + val bucket = WaypointAudioDuration.classify(durationMs) + val bucketColor = when (bucket) { + de.waypointaudio.util.AudioLengthBucket.SHORT -> 0xFF66BB6A.toInt() + de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 0xFFFFA726.toInt() + de.waypointaudio.util.AudioLengthBucket.LONG -> 0xFFE53935.toInt() + else -> fillArgb + } + val fraction = WaypointAudioDuration.normalizedFraction(durationMs).coerceIn(0f, 1f) + val sweep = 360f * fraction + if (sweep > 0.5f) { + val progress = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeWidth = ringStroke + color = bucketColor + } + val rectF = android.graphics.RectF(cx - ringMid, cy - ringMid, cx + ringMid, cy + ringMid) + canvas.drawArc(rectF, -90f, sweep, false, progress) + } + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.style = Paint.Style.FILL + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius + halo, paint) + paint.color = fillArgb + canvas.drawCircle(cx, cy, visibleRadius, paint) + paint.style = Paint.Style.STROKE + paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density + paint.color = 0xFF202020.toInt() + canvas.drawCircle(cx, cy, visibleRadius, paint) + + return bmp +} + +// ─────────────────────────────────────────────────────────────────────────── +// Geo-Helfer +// ─────────────────────────────────────────────────────────────────────────── + +private fun circleLatLng( + latDeg: Double, + lonDeg: Double, + radiusMeters: Double, + steps: Int +): List { + val out = mutableListOf() + val earthRadius = 6_371_000.0 + val lat0 = Math.toRadians(latDeg) + val lon0 = Math.toRadians(lonDeg) + val d = radiusMeters / earthRadius + for (i in 0 until steps) { + val bearing = Math.toRadians(i * 360.0 / steps) + val lat1 = Math.asin( + Math.sin(lat0) * Math.cos(d) + Math.cos(lat0) * Math.sin(d) * Math.cos(bearing) + ) + val lon1 = lon0 + Math.atan2( + Math.sin(bearing) * Math.sin(d) * Math.cos(lat0), + Math.cos(d) - Math.sin(lat0) * Math.sin(lat1) + ) + out += LatLng(Math.toDegrees(lat1), Math.toDegrees(lon1)) + } + out += out.first() + return out +} + +private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dPhi = Math.toRadians(lat2 - lat1) + val dLam = Math.toRadians(lon2 - lon1) + val a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) + + Math.cos(phi1) * Math.cos(phi2) * + Math.sin(dLam / 2) * Math.sin(dLam / 2) + val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return r * c +} + +private fun cumulativeMeters(line: List): DoubleArray { + val out = DoubleArray(line.size) + var acc = 0.0 + out[0] = 0.0 + for (i in 1 until line.size) { + acc += haversineMeters( + line[i - 1].latitude, line[i - 1].longitude, + line[i].latitude, line[i].longitude + ) + out[i] = acc + } + return out +} + +private fun projectOntoGeometry( + line: List, + cumulative: DoubleArray, + lat: Double, + lon: Double +): Double { + // Wir verwenden die Position des nächstgelegenen Linienknotens als Anker. + // Das ist robust und ohne 2D-Projektion in Metern hinreichend genau. + var bestI = 0 + var bestDist = Double.MAX_VALUE + for (i in line.indices) { + val d = haversineMeters(lat, lon, line[i].latitude, line[i].longitude) + if (d < bestDist) { + bestDist = d + bestI = i + } + } + return cumulative[bestI] +} + +private fun sliceGeometry( + line: List, + cumulative: DoubleArray, + startM: Double, + endM: Double +): List { + if (line.size < 2) return emptyList() + val totalLen = cumulative.last() + val s = startM.coerceIn(0.0, totalLen) + val e = endM.coerceIn(0.0, totalLen) + if (e <= s) return emptyList() + val out = mutableListOf() + out += interpolateOnLine(line, cumulative, s) + for (i in line.indices) { + if (cumulative[i] > s && cumulative[i] < e) { + out += line[i] + } + } + out += interpolateOnLine(line, cumulative, e) + return out +} + +private fun interpolateOnLine( + line: List, + cumulative: DoubleArray, + distanceM: Double +): LatLng { + if (line.size == 1) return line[0] + if (distanceM <= 0.0) return line.first() + if (distanceM >= cumulative.last()) return line.last() + var i = 1 + while (i < cumulative.size && cumulative[i] < distanceM) i++ + val a = line[i - 1] + val b = line[i] + val da = cumulative[i - 1] + val db = cumulative[i] + val t = if (db > da) (distanceM - da) / (db - da) else 0.0 + return LatLng( + a.latitude + (b.latitude - a.latitude) * t, + a.longitude + (b.longitude - a.longitude) * t + ) +} + +// ─────────────────────────────────────────────────────────────────────────── +// Style-Builder (v2.5.37) +// ─────────────────────────────────────────────────────────────────────────── + +/** + * v2.5.37 — Klassischer OSM-Raster-Style als JSON. + * + * Wird nur noch für MapBaseStyle.CLASSIC verwendet (Fallback). + * Alle anderen Stile werden über OpenFreeMap-Style-URLs geladen + * (Style.Builder().fromUri(url)). + * + * Attribution: © OpenStreetMap-Mitwirkende (ODbL). + */ +private fun buildClassicOsmStyleJson(): String { + val osmTile = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + val osmAttr = "© OpenStreetMap-Mitwirkende (ODbL)" + return """ +{ + "version": 8, + "name": "GPS2Audio Classic OSM v2.5.37", + "sources": { + "explorer-tiles": { + "type": "raster", + "tiles": ["$osmTile"], + "tileSize": 256, + "minzoom": 0, + "maxzoom": 19, + "attribution": "$osmAttr" + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { "background-color": "#F5F3EE" } + }, + { + "id": "explorer-tiles-layer", + "type": "raster", + "source": "explorer-tiles", + "minzoom": 0, + "maxzoom": 22, + "paint": { + "raster-brightness-min": 0.0, + "raster-brightness-max": 1.0, + "raster-saturation": 0.0, + "raster-contrast": 0.0 + } + } + ] +} +""".trimIndent() +} + +/** + * v2.5.37 — Fügt Gebäude-Extrusion (fill-extrusion) zum geladenen MapLibre-Style hinzu, + * falls eine building-kompatible Source vorhanden ist. + * + * Strategie: Sucht nach einem vorhandenen Layer mit Typ "fill" und Source-Layer "building" + * im geladenen Style. Wenn vorhanden, wird ein fill-extrusion-Layer auf derselben Source + * hinzugefügt. Bei fehlendem Layer oder Source: kein Crash, einfacher 2D-Fallback. + * + * OpenFreeMap Positron enthält building-Daten in der OpenMapTiles-Schema-Source. + * + * Die Extrusion-Höhe wird aus dem "height"-Attribut des building-Features bezogen + * (Fallback: 10m). Dach-Abstand aus "min_height" (Fallback: 0m). + */ +private fun addBuildingExtrusionIfAvailable(style: Style) { + // Prüfe ob bereits ein extrusion-Layer vorhanden ist (z.B. nach Doppelaufruf). + val existingExtrusionId = "gps2audio-building-extrusion" + if (style.getLayer(existingExtrusionId) != null) return + + // Im OpenMapTiles-Schema (verwendet von OpenFreeMap Positron) heißt die + // Vektor-Source "openmaptiles" und der Source-Layer für Gebäude "building". + val buildingSourceLayer = "building" + + // Suche VOR dem ersten Label/Symbol-Layer einzufügen, + // damit Text über Gebäuden sichtbar bleibt. + val layers = style.layers + val firstSymbolLayerId = layers + .firstOrNull { it is org.maplibre.android.style.layers.SymbolLayer } + ?.id + + try { + // Versuche, den Layer über die MapLibre-JSON-API hinzuzufügen. + // Wir nutzen addLayerBelow falls ein Symbol-Layer existiert. + if (firstSymbolLayerId != null) { + style.addLayerBelow( + org.maplibre.android.style.layers.FillExtrusionLayer(existingExtrusionId, "openmaptiles") + .apply { + sourceLayer = buildingSourceLayer + minZoom = 14f + setProperties( + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionColor( + org.maplibre.android.style.expressions.Expression.interpolate( + org.maplibre.android.style.expressions.Expression.linear(), + org.maplibre.android.style.expressions.Expression.get("render_height"), + org.maplibre.android.style.expressions.Expression.stop(0f, org.maplibre.android.style.expressions.Expression.color(android.graphics.Color.parseColor("#EAEEF2"))), + org.maplibre.android.style.expressions.Expression.stop(200f, org.maplibre.android.style.expressions.Expression.color(android.graphics.Color.parseColor("#C8D4E0"))) + ) + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionHeight( + org.maplibre.android.style.expressions.Expression.interpolate( + org.maplibre.android.style.expressions.Expression.linear(), + org.maplibre.android.style.expressions.Expression.zoom(), + org.maplibre.android.style.expressions.Expression.stop(14f, 0f), + org.maplibre.android.style.expressions.Expression.stop(16f, + org.maplibre.android.style.expressions.Expression.get("render_height")) + ) + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionBase( + org.maplibre.android.style.expressions.Expression.interpolate( + org.maplibre.android.style.expressions.Expression.linear(), + org.maplibre.android.style.expressions.Expression.zoom(), + org.maplibre.android.style.expressions.Expression.stop(14f, 0f), + org.maplibre.android.style.expressions.Expression.stop(16f, + org.maplibre.android.style.expressions.Expression.get("render_min_height")) + ) + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionOpacity(0.82f) + ) + }, + firstSymbolLayerId + ) + } else { + style.addLayer( + org.maplibre.android.style.layers.FillExtrusionLayer(existingExtrusionId, "openmaptiles") + .apply { + sourceLayer = buildingSourceLayer + minZoom = 14f + setProperties( + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionColor( + android.graphics.Color.parseColor("#EAEEF2") + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionHeight( + org.maplibre.android.style.expressions.Expression.get("render_height") + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionBase( + org.maplibre.android.style.expressions.Expression.get("render_min_height") + ), + org.maplibre.android.style.layers.PropertyFactory.fillExtrusionOpacity(0.82f) + ) + } + ) + } + } catch (_: Exception) { + // 2D-Fallback: Extrusion konnte nicht eingetragen werden (fehlende Source, falscher Schema). + // Karte bleibt in 2D-Ansicht — kein Crash. + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Drafts/Storyboard Panels — Delegation an die bewährten Implementierungen +// in ExplorerScreen.kt (dort als `internal fun` exponiert), um Duplizierung +// und Compose-Scope-Probleme zu vermeiden. +// ─────────────────────────────────────────────────────────────────────────── + +@Composable +private fun DraftsPanelMl( + drafts: List, + activeDraftId: String?, + isRecording: Boolean, + onActivate: (String) -> Unit, + onOpenFullEditor: (String) -> Unit, + onDelete: (String) -> Unit, + onImport: (String) -> Unit, + onClose: () -> Unit +) { + DraftsPanel( + drafts = drafts, + activeDraftId = activeDraftId, + isRecording = isRecording, + onActivate = onActivate, + onOpenFullEditor = onOpenFullEditor, + onDelete = onDelete, + onImport = onImport, + onClose = onClose + ) +} + +@Composable +private fun StoryboardPanelMl( + tourWaypoints: List, + currentTour: String, + onSaveAsTour: (targetTour: String, orderedIds: List) -> Unit, + onClose: () -> Unit +) { + StoryboardPanel( + tourWaypoints = tourWaypoints, + currentTour = currentTour, + onSaveAsTour = onSaveAsTour, + onClose = onClose + ) +} + +// ─────────────────────────────────────────────────────────────────────────── +// v2.5.38 — POI-Layer-Sichtbarkeit +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Schaltet Symbol-Layer des geladenen MapLibre-Styles nach POI-Kategorie + * sichtbar/unsichtbar. + * + * Strategie: Heuristische Layer-ID-Präfixe auf Basis des OpenMapTiles-Schemas, + * das OpenFreeMap Positron, Dark und Liberty verwenden. Wenn ein Layer-ID nicht + * existiert, passiert nichts (kein Crash). + * + * Einschränkungen (dokumentiert): + * - Layer-IDs sind style-abhängig und können sich bei OpenFreeMap-Updates ändern. + * - Die Heuristik deckt die häufigsten Symbol-Layer ab, nicht jeden Einzelfall. + * - Im CLASSIC-Raster-Modus nicht anwendbar (kein Vektor-Layer). + * + * OpenMapTiles-Schema Layer-ID-Muster (häufig): + * - Sehenswürdigkeiten: "poi-*", "tourism-*", "historic-*", "attraction-*" + * - Infrastruktur: "amenity-*", "health-*", "emergency-*", "post-*" + * - Gastronomie: "food-*", "catering-*", "restaurant-*" + * - Mobilität: "transit-*", "transport-*", "railway-*", "bus-*", "parking-*" + */ +private fun applyPoiLayerVisibility( + style: org.maplibre.android.maps.Style, + poi: de.waypointaudio.data.PoiLayerSettings +) { + val allLayers = runCatching { style.layers }.getOrNull() ?: return + + // Kategorien-Zuordnung: Layer-ID-Präfixe/Substring-Heuristik + val sightsPrefixes = listOf( + "poi", "tourism", "historic", "attraction", "landmark", + "sightseeing", "monument", "museum", "zoo", "aquarium", + "castle", "church", "chapel", "mosque", "temple", "shrine" + ) + val infrastructurePrefixes = listOf( + "health", "hospital", "pharmacy", "clinic", "doctor", + "emergency", "police", "fire", "post", "bank", "atm", + "fuel", "amenity-fuel", "amenity-bank", "amenity-atm", + "amenity-hospital", "amenity-pharmacy", "amenity-post" + ) + val gastronomyPrefixes = listOf( + "food", "catering", "restaurant", "cafe", "fast-food", + "fast_food", "bar", "pub", "biergarten", "ice-cream", + "ice_cream", "bakery", "amenity-restaurant", "amenity-cafe", + "amenity-bar", "amenity-pub", "amenity-fast" + ) + val mobilityPrefixes = listOf( + "transit", "transport", "railway", "bus", "subway", + "tram", "ferry", "taxi", "bicycle", "parking", + "car-sharing", "car_sharing", "rental", "station", + "stop", "aeroway", "airport", "helipad" + ) + + for (layer in allLayers) { + // Nur Symbol-Layer (POI-Icons/Labels) verarbeiten. + if (layer !is org.maplibre.android.style.layers.SymbolLayer) continue + val id = layer.id.lowercase() + + val isSights = sightsPrefixes.any { id.contains(it) } + val isInfra = infrastructurePrefixes.any { id.contains(it) } + val isGastro = gastronomyPrefixes.any { id.contains(it) } + val isMobility = mobilityPrefixes.any { id.contains(it) } + + // Nur Layer anpassen die einer POI-Kategorie zugeordnet sind. + // Nicht-POI-Symbol-Layer (Labels, Straßennamen etc.) werden nicht angefasst. + if (!isSights && !isInfra && !isGastro && !isMobility) continue + + val visible = when { + isSights -> poi.showSights + isInfra -> poi.showInfrastructure + isGastro -> poi.showGastronomy + isMobility -> poi.showMobility + else -> true + } + runCatching { + layer.setProperties( + org.maplibre.android.style.layers.PropertyFactory.visibility( + if (visible) org.maplibre.android.style.layers.Property.VISIBLE + else org.maplibre.android.style.layers.Property.NONE + ) + ) + } + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// v2.5.38 — Kompaktes Attribution-Badge (Pflichtangabe, schöner als v2.5.37) +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Kompaktes, anklickbares Attribution-Badge unten links auf der Karte. + * + * Zeigt "© OpenStreetMap · ODbL" — klickt auf openstreetmap.org/copyright. + * Pflichtangabe darf nicht von Buttons überdeckt werden. + * Design: semi-transparent, harmoniert mit dem Karten-Design. + */ +@Composable +fun MapCompactAttributionBadge(modifier: Modifier = Modifier) { + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + androidx.compose.material3.Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.80f), + modifier = modifier + .clickable { + runCatching { + uriHandler.openUri("https://www.openstreetmap.org/copyright") + } + } + ) { + androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) { + Text( + text = "© OpenStreetMap · ODbL", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + ) + } + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// v2.5.42 — Explorer-POI-Icon-Builder: klar unterscheidbare Kategorie-Marker +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Erstellt ein klar unterscheidbares Bitmap-Icon für jeden POI-Typ. + * + * v2.5.42 — Fix für "alle blaue Punkte": + * 1. Jede Kategorie hat EIGENE Farbe + EIGENE Form/Symbol-Kombo. + * 2. Bitmaps haben kategorie-spezifische Sentinel-Pixel an definierter + * Position, damit MapLibre IconFactory keinen Icon-Cache-Hit erzeugt + * (gleiche Bitmap-Größe → gleicher Hash-Bucket ohne Sentinel). + * 3. Kein Default-Blau: fillColor kommt ausschließlich aus dem + * Kategorie-Mapping, kein Fallback auf die System-Primärfarbe. + * + * Kategorie-Schema: + * SIGHTS — Türkis (#00BCD4) + Rauten-Shape + Stern-Symbol + * GASTRONOMY — Orange (#FF7043) + Kreis + Tassen-Symbol (C) + * MOBILITY — Indigo (#3F51B5) + Abgerundetes Quadrat + Bus-Symbol (B) + * INFRASTRUCTURE — Koralle (#E53935) + Kreis + Plus-Symbol (+) + */ +private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): Bitmap { + // v2.5.43 — Echte Canvas-Piktogramme statt Buchstaben/Zeichen + val sizePx = 56 + + // Kategorie-Farben — dezent, App-Teal-Stil passend + val fillColor = when (category) { + de.waypointaudio.data.PoiCategory.SIGHTS -> 0xFF00897B.toInt() // Teal-Grün (Sehenswürdigkeit) + de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xFFBF6600.toInt() // Amber-Braun (Gastronomie) + de.waypointaudio.data.PoiCategory.MOBILITY -> 0xFF1565C0.toInt() // Dunkelblau (Mobilität) + de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF558B2F.toInt() // Dunkelgrün (Infrastruktur/Info) + } + + // Sentinel-Byte-Wert je Kategorie (eindeutige Pixel-Markierung für MapLibre-Cache) + val sentinelAlpha = when (category) { + de.waypointaudio.data.PoiCategory.SIGHTS -> 0xFC + de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xFD + de.waypointaudio.data.PoiCategory.MOBILITY -> 0xFE + de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF + } + + val bmp = android.graphics.Bitmap.createBitmap(sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bmp) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) + + val cx = sizePx / 2f + val cy = sizePx / 2f + + // ── Hintergrundform je Kategorie ───────────────────────────────────────── + when (category) { + de.waypointaudio.data.PoiCategory.SIGHTS -> { + // Fünfzackiger Stern als Marker-Hintergrund + val outerR = sizePx / 2f - 3f + val innerR = outerR * 0.48f + val path = android.graphics.Path() + for (i in 0 until 10) { + val angle = Math.toRadians((-90.0 + i * 36.0)) + val r = if (i % 2 == 0) outerR else innerR + val x = cx + (r * Math.cos(angle)).toFloat() + val y = cy + (r * Math.sin(angle)).toFloat() + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + path.close() + // Schatten + paint.color = 0x30000000.toInt() + paint.style = android.graphics.Paint.Style.FILL + canvas.save(); canvas.translate(1.5f, 1.5f); canvas.drawPath(path, paint); canvas.restore() + // Füllung + paint.color = fillColor + canvas.drawPath(path, paint) + // Weißer Rand + paint.color = 0xFFFFFFFF.toInt() + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 2.5f + canvas.drawPath(path, paint) + } + de.waypointaudio.data.PoiCategory.GASTRONOMY -> { + // Kreis als Badge-Hintergrund + val r = sizePx / 2f - 4f + paint.color = 0x30000000.toInt() + paint.style = android.graphics.Paint.Style.FILL + canvas.drawCircle(cx + 1.5f, cy + 1.5f, r, paint) + paint.color = fillColor + canvas.drawCircle(cx, cy, r, paint) + paint.color = 0xFFFFFFFF.toInt() + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 2.5f + canvas.drawCircle(cx, cy, r, paint) + } + de.waypointaudio.data.PoiCategory.MOBILITY -> { + // Abgerundetes Rechteck + val margin = 5f + val rect = android.graphics.RectF(margin, margin + 2f, sizePx - margin, sizePx - margin - 2f) + val corner = 10f + paint.color = 0x30000000.toInt() + paint.style = android.graphics.Paint.Style.FILL + val sRect = android.graphics.RectF(margin + 1.5f, margin + 3.5f, sizePx - margin + 1.5f, sizePx - margin - 0.5f) + canvas.drawRoundRect(sRect, corner, corner, paint) + paint.color = fillColor + canvas.drawRoundRect(rect, corner, corner, paint) + paint.color = 0xFFFFFFFF.toInt() + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 2.5f + canvas.drawRoundRect(rect, corner, corner, paint) + } + de.waypointaudio.data.PoiCategory.INFRASTRUCTURE -> { + // Kreis mit dickem Rand + val r = sizePx / 2f - 4f + paint.color = 0x30000000.toInt() + paint.style = android.graphics.Paint.Style.FILL + canvas.drawCircle(cx + 1.5f, cy + 1.5f, r, paint) + paint.color = fillColor + canvas.drawCircle(cx, cy, r, paint) + paint.color = 0xFFFFFFFF.toInt() + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 3.5f + canvas.drawCircle(cx, cy, r, paint) + } + } + + // ── Weißes Piktogramm per Canvas ───────────────────────────────────────── + paint.color = 0xFFFFFFFF.toInt() + paint.style = android.graphics.Paint.Style.FILL + paint.strokeWidth = 2.2f + + when (category) { + de.waypointaudio.data.PoiCategory.SIGHTS -> { + // Stern-Marker: Stern-Hintergrund ist schon der Rahmen. + // Piktogramm: kleiner innerer Punkt + Strahllinien wie eine Landmarke/Monument-Nadel + // Vertikaler Stab (Monument-Pin) + val stW = 3.5f + val stH = 14f + val stTop = cy - 10f + val stBot = cy + 4f + val stLeft = cx - stW / 2f + canvas.drawRect(stLeft, stTop, stLeft + stW, stBot, paint) + // Kleines Dreieck-Dach oben (Turm/Monument) + val roofPath = android.graphics.Path().apply { + moveTo(cx, stTop - 5f) + lineTo(cx - 5f, stTop) + lineTo(cx + 5f, stTop) + close() + } + canvas.drawPath(roofPath, paint) + // Sockel/Basis unten + canvas.drawRect(cx - 6f, stBot, cx + 6f, stBot + 2.5f, paint) + } + de.waypointaudio.data.PoiCategory.GASTRONOMY -> { + // Tasse mit Henkel und Dampf-Linie + val cupLeft = cx - 9f + val cupRight = cx + 9f + val cupTop = cy - 3f + val cupBot = cy + 9f + // Tassenform: Trapez (breiter unten) + val cupPath = android.graphics.Path().apply { + moveTo(cupLeft + 2f, cupTop) + lineTo(cupLeft, cupBot) + lineTo(cupRight, cupBot) + lineTo(cupRight - 2f, cupTop) + close() + } + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 2.3f + canvas.drawPath(cupPath, paint) + // Untertasse + paint.strokeWidth = 2f + canvas.drawLine(cupLeft - 2f, cupBot + 2f, cupRight + 2f, cupBot + 2f, paint) + // Henkel rechts + val hRect = android.graphics.RectF(cupRight - 1f, cupTop + 4f, cupRight + 6f, cupBot - 2f) + canvas.drawArc(hRect, -90f, 180f, false, paint) + // Dampf-Wellenlinie oben (eine kleine Kurve) + paint.strokeWidth = 1.8f + val steamPath = android.graphics.Path().apply { + moveTo(cx - 3f, cupTop - 3f) + quadTo(cx - 1f, cupTop - 7f, cx + 1f, cupTop - 3f) + quadTo(cx + 3f, cupTop + 1f, cx + 5f, cupTop - 3f) + } + canvas.drawPath(steamPath, paint) + } + de.waypointaudio.data.PoiCategory.MOBILITY -> { + // Bus-Silhouette: rechteckiger Körper, Räder, Fenster + val busLeft = cx - 10f + val busRight = cx + 10f + val busTop = cy - 8f + val busBot = cy + 6f + // Buskarosserie + paint.style = android.graphics.Paint.Style.STROKE + paint.strokeWidth = 2.2f + val busRect = android.graphics.RectF(busLeft, busTop, busRight, busBot) + canvas.drawRoundRect(busRect, 3f, 3f, paint) + // Frontscheibe (linke Seite) + canvas.drawLine(busLeft + 2f, busTop + 2f, busLeft + 2f, busTop + 7f, paint) + // Fenster 1 + val w1 = android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f) + paint.strokeWidth = 1.5f + canvas.drawRoundRect(w1, 1f, 1f, paint) + // Fenster 2 + val w2 = android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f) + canvas.drawRoundRect(w2, 1f, 1f, paint) + // Rad links + paint.strokeWidth = 2f + canvas.drawCircle(busLeft + 5f, busBot + 1f, 3f, paint) + // Rad rechts + canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint) + } + de.waypointaudio.data.PoiCategory.INFRASTRUCTURE -> { + // Info-"i"-Piktogramm: großer Punkt oben, vertikaler Stab unten + paint.style = android.graphics.Paint.Style.FILL + // Punkt oben + canvas.drawCircle(cx, cy - 8f, 3.5f, paint) + // Stab unten — leicht breiter, abgerundet + val stW2 = 4.5f + val rect2 = android.graphics.RectF(cx - stW2 / 2f, cy - 2f, cx + stW2 / 2f, cy + 9f) + canvas.drawRoundRect(rect2, 2f, 2f, paint) + // Basis-Strich + canvas.drawRoundRect(android.graphics.RectF(cx - 7f, cy + 8f, cx + 7f, cy + 10f), 2f, 2f, paint) + } + } + + // Sentinel-Pixel: kategorie-spezifischer Alpha-Wert verhindert MapLibre-Cache-Kollision + bmp.setPixel(0, 0, (sentinelAlpha shl 24) or 0x000000) + + return bmp +} + +private fun buildExplorerPoiIconWithLabel( + category: de.waypointaudio.data.PoiCategory, + name: String +): Bitmap { + val iconSize = 56 // gleiche Größe wie buildExplorerPoiIcon + val labelMaxChars = 14 + val displayName = if (name.length > labelMaxChars) name.take(labelMaxChars - 1) + "…" else name + + val labelPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + textSize = 20f + textAlign = android.graphics.Paint.Align.CENTER + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + isFakeBoldText = true + } + val textWidth = labelPaint.measureText(displayName).toInt() + val pillW = (textWidth + 14).coerceAtLeast(iconSize) + val pillH = 26 + val pillMargin = 2 + val totalH = iconSize + pillMargin + pillH + val totalW = maxOf(iconSize, pillW) + 4 + + val bmp = android.graphics.Bitmap.createBitmap(totalW, totalH, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bmp) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) + + // Zeichne Kategorie-Icon zentriert oben + val iconBmp = buildExplorerPoiIcon(category) + val iconLeft = (totalW - iconSize) / 2f + canvas.drawBitmap(iconBmp, iconLeft, 0f, null) + iconBmp.recycle() + + // Label-Pill darunter — gleiche Farbe wie Kategorie, leicht transparent + val pillColor = when (category) { + de.waypointaudio.data.PoiCategory.SIGHTS -> 0xCC00897B.toInt() // Teal-Grün passend zu Piktogramm + de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xCCBF6600.toInt() // Amber-Braun passend zu Piktogramm + de.waypointaudio.data.PoiCategory.MOBILITY -> 0xCC1565C0.toInt() // Dunkelblau passend zu Piktogramm + de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xCC558B2F.toInt() // Dunkelgrün passend zu Piktogramm + } + val pillTop = iconSize + pillMargin.toFloat() + val pillLeft = (totalW - pillW) / 2f + val pillRight = pillLeft + pillW + val pillBottom = pillTop + pillH + + paint.color = pillColor + paint.style = android.graphics.Paint.Style.FILL + val pillRect = android.graphics.RectF(pillLeft, pillTop, pillRight, pillBottom) + canvas.drawRoundRect(pillRect, 7f, 7f, paint) + + // Label-Text + labelPaint.color = 0xFFFFFFFF.toInt() + val textBounds = android.graphics.Rect() + labelPaint.getTextBounds(displayName, 0, displayName.length, textBounds) + val textX = totalW / 2f + val textY = pillTop + pillH / 2f + textBounds.height() / 2f - textBounds.bottom + canvas.drawText(displayName, textX, textY, labelPaint) + + return bmp +} + +// ─────────────────────────────────────────────────────────────────────────── +// v2.5.41 — POI-Legende (einklappbar, kompakt, über Attribution) +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Kompakte, einklappbare POI-Kategorie-Legende. + * Zeigt farbige Kreise mit Kategorie-Symbol und Kurzlabel. + * Toggle-Klick klappt die Legende auf/zu. + * Positionierung: bottom-start, über der Attribution (nicht überlagern). + */ +@Composable +private fun ExplorerPoiLegend( + poiLayers: de.waypointaudio.data.PoiLayerSettings, + expanded: Boolean, + onToggle: () -> Unit, + modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier +) { + // v2.5.43 — Legende mit PoiCategory-Referenz für Canvas-Piktogramme + data class LegendEntry(val category: de.waypointaudio.data.PoiCategory, val color: Long, val label: String, val visible: Boolean) + + val entries = listOf( + LegendEntry(de.waypointaudio.data.PoiCategory.SIGHTS, 0xFF00897B, "Sehensw.", poiLayers.showSights), + LegendEntry(de.waypointaudio.data.PoiCategory.GASTRONOMY, 0xFFBF6600, "Gastro", poiLayers.showGastronomy), + LegendEntry(de.waypointaudio.data.PoiCategory.MOBILITY, 0xFF1565C0, "Mobilit.", poiLayers.showMobility), + LegendEntry(de.waypointaudio.data.PoiCategory.INFRASTRUCTURE, 0xFF558B2F, "Infra", poiLayers.showInfrastructure), + ).filter { it.visible } + + if (entries.isEmpty()) return + + androidx.compose.material3.Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.90f), + shadowElevation = 2.dp, + modifier = modifier.clickable { onToggle() } + ) { + if (!expanded) { + // Kompakt: nur Farbpunkte nebeneinander + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "POI", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + entries.forEach { entry -> + androidx.compose.foundation.Canvas(modifier = Modifier.size(10.dp)) { + drawCircle(androidx.compose.ui.graphics.Color(entry.color)) + } + } + } + } else { + // Ausgeklappt: Einträge mit Symbol + Label + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + entries.forEach { entry -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + // v2.5.43 — Farbiger Kategorie-Kreis (konsistent mit Mini-Punkt in Kompakt-Ansicht) + androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) { + drawCircle(color = androidx.compose.ui.graphics.Color(entry.color)) + } + Text( + entry.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + } + Text( + "antippen zum Einklappen", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), + modifier = Modifier.padding(top = 1.dp) + ) + } + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt new file mode 100644 index 0000000..1913300 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/HomeScreen.kt @@ -0,0 +1,948 @@ +package de.waypointaudio.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +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 androidx.compose.ui.unit.sp +import de.waypointaudio.R +import de.waypointaudio.data.HomeModulesSettings +import de.waypointaudio.data.HomeModulesSettingsStore +import de.waypointaudio.data.MusicSourceType +import de.waypointaudio.data.TourCover +import de.waypointaudio.data.TourCoverIcon +import de.waypointaudio.data.TourCoverPalette +import de.waypointaudio.data.Waypoint +import de.waypointaudio.license.LicenseFeatureAccess +import de.waypointaudio.license.LicenseStatusProvider +import de.waypointaudio.service.LivePttManager +import de.waypointaudio.viewmodel.WaypointViewModel +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** + * v2.5.0 — Neue Hauptseite "Audio-Bibliothek". + * + * Touren erscheinen als Cover-Karten in einem Grid. Der Klick auf eine Karte + * setzt die Tour aktiv und öffnet die bestehende Tour-/Waypoint-Ansicht + * ([WaypointListScreen]) — alle bisherigen Funktionen (Drag-and-drop, + * Audiozuordnung, Atmo, Begleitmusik, Backup, Touren ordnen) bleiben dort + * vollständig erreichbar. + * + * Optionale Module für PTT und Atmo werden über das Drei-Punkt-Menü + * "Startseite anpassen" gesteuert ([HomeModulesSettingsStore]). Bei aktivem + * Modul erscheint ein kompakter Block direkt auf der Startseite. + * + * Cover sind automatisch aus der GPS2Audio-Farbwelt (Teal/Türkis, dunkle + * Verläufe, Akzentlinien) generiert — keine externen Bilder nötig. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: WaypointViewModel, + onOpenTour: (String) -> Unit, + onOpenExplorer: () -> Unit, + onOpenClassicView: () -> Unit +) { + val context = LocalContext.current + val store = remember(context) { HomeModulesSettingsStore(context.applicationContext) } + val scope = rememberCoroutineScope() + + val tourList by viewModel.tourList.collectAsState() + val waypoints by viewModel.waypoints.collectAsState() + val tourRoutes by viewModel.tourRoutes.collectAsState() + val tourCovers by viewModel.tourCovers.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val serviceRunning by viewModel.serviceRunning.collectAsState() + // musicSettings: auf Home nicht mehr verwendet (Atmo-Modul entfernt in v2.5.30). + // Wird weiterhin in WaypointListScreen/Tour-Ansicht genutzt. + val musicPlaying by viewModel.musicPlaying.collectAsState() + val manualPlaying by viewModel.manualPlaying.collectAsState() + val manualCurrentName by viewModel.manualCurrentName.collectAsState() + + val homeModules by store.settings.collectAsState(initial = HomeModulesSettings()) + + // v2.5.32 — Feature-Gate: PTT erfordert Profi-Zugang. + val licenseStatus = LicenseStatusProvider.status + val canPtt = LicenseFeatureAccess.canUsePtt(licenseStatus) + + var showOverflow by remember { mutableStateOf(false) } + var showCustomize by remember { mutableStateOf(false) } + var showAbout by remember { mutableStateOf(false) } + // v2.5.19 — Tour-Sortier-Dialog auf der Hauptseite + var showTourReorder by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + // v2.5.8 — Wiedergabe-zuerst-UI: Der große GPS2Audio-Schriftzug ist + // raus. Stattdessen kompakte Topbar mit Brand-Icon links und + // schlankem „Audio-Bibliothek"-Label. So wirkt die Startseite mehr + // wie eine Audio-/Tour-Bibliothek, weniger wie ein technisches + // Dashboard. Aktionen (Explorer + Overflow) bleiben unverändert. + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher), + contentDescription = "GPS2Audio", + modifier = Modifier.size(28.dp) + ) + Text( + text = "Audio-Bibliothek", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + actions = { + IconButton(onClick = onOpenExplorer) { + Icon(Icons.Filled.Explore, contentDescription = "Explorer") + } + IconButton(onClick = { showOverflow = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Menü") + } + DropdownMenu( + expanded = showOverflow, + onDismissRequest = { showOverflow = false } + ) { + DropdownMenuItem( + text = { Text("Startseite anpassen") }, + leadingIcon = { Icon(Icons.Filled.Tune, null) }, + onClick = { + showOverflow = false + showCustomize = true + } + ) + DropdownMenuItem( + text = { Text("Bearbeitungsmodus") }, + leadingIcon = { Icon(Icons.Filled.ViewList, null) }, + onClick = { + showOverflow = false + onOpenClassicView() + } + ) + // v2.5.19 — „Touren ordnen" auf der Hauptseite/Audio- + // Bibliothek: Der Einstieg wurde aus der einzelnen + // Touransicht (WaypointListScreen) entfernt, weil er + // dort kein Tour-spezifischer Schritt ist, sondern eine + // Verwaltungsaktion über die gesamte Tourliste hinweg. + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_tour_reorder)) }, + leadingIcon = { Icon(Icons.Filled.SwapVert, null) }, + onClick = { + showOverflow = false + showTourReorder = true + }, + enabled = tourList.size > 1 + ) + // v2.5.19 — „Track Editor" (Track-Entwürfe) aus dem + // Hauptmenü entfernt, weil dieselbe Funktion über den + // Explorer erreichbar ist (Explorer öffnet einen Draft + // im Vollbild-Editor). + DropdownMenuItem( + text = { Text("Explorer / Karte") }, + leadingIcon = { Icon(Icons.Filled.Map, null) }, + onClick = { + showOverflow = false + onOpenExplorer() + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text("Über GPS2Audio") }, + leadingIcon = { Icon(Icons.Filled.Info, null) }, + onClick = { + showOverflow = false + showAbout = true + } + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary + ) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // v2.5.8 — Hero-Kachel mit großem "Deine Audio-Touren · X Touren · Y + // Wegpunkte" wurde entfernt. Sie wirkte als zweiter technischer + // Header neben der TopAppBar. Der Explorer-Schnellzugriff bleibt + // über das TopAppBar-Icon und das Overflow-Menü vollständig + // erreichbar (siehe oben). Das spart vertikalen Platz für die + // eigentliche Audio-Bibliothek. + + // ─ Atmo-Modul: auf der Startseite nicht angezeigt (v2.5.30). + // Atmo ist weiterhin in der Tour-/WaypointListScreen verfügbar. + // (homeModules.showAtmo wird auf der Home-Seite ignoriert) + + // ─ Optionales PTT-Modul ─ + // v2.5.32: PTT-Modul nur anzeigen wenn Profi-Zugang vorhanden. + // PTT-Aufnahmen bleiben ohne Profi-Zugang erhalten (nicht gelöscht). + if (homeModules.showPtt && canPtt) { + PttModuleCard( + pttManager = viewModel.pttManager, + onOpenRecordings = { onOpenClassicView() } + ) + } + + // ─ Bibliothek-Header ─ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.LibraryMusic, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Touren", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Text( + text = "${tourList.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // ─ Cover-Grid ─ + // Wir nutzen ein einfaches Wrap-Layout statt LazyVerticalGrid in + // einer scrollbaren Column, um Compose-Nested-Scroll-Konflikte + // zu vermeiden. + val columns = 2 + val rows = tourList.chunked(columns) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + rows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + row.forEach { tour -> + val tourWaypoints = waypoints.count { + it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour + } + val hasRoute = tourRoutes[tour] != null + val isActive = tour == selectedTour && serviceRunning + val isSelected = tour == selectedTour + TourCoverCard( + tourName = tour, + waypointCount = tourWaypoints, + hasRoute = hasRoute, + isActive = isActive, + isSelected = isSelected, + cover = tourCovers[tour], + modifier = Modifier.weight(1f), + onClick = { + viewModel.selectTour(tour) + onOpenTour(tour) + } + ) + } + // fill empty slot in last row + if (row.size < columns) { + Spacer(Modifier.weight(1f)) + } + } + } + } + + // ─ Now-Playing-Bar (kompakt, am Ende) ─ + if (homeModules.showMiniNowPlaying) { + val isPlayingAnything = musicPlaying || manualPlaying + AnimatedVisibility(visible = isPlayingAnything) { + MiniNowPlayingBar( + atmoPlaying = musicPlaying, + manualPlaying = manualPlaying, + manualName = manualCurrentName, + tourName = selectedTour, + onStopAtmo = { viewModel.stopMusicPlayback() }, + onStopManual = { viewModel.manualStop() } + ) + } + } + + Spacer(Modifier.height(80.dp)) + } + } + + if (showCustomize) { + CustomizeHomeDialog( + settings = homeModules, + canPtt = canPtt, + onSetShowPtt = { v -> scope.launch { store.setShowPtt(v) } }, + // v2.5.30: Atmo-Toggle auf Startseite entfernt; Atmo bleibt in Tour-Ansicht. + onSetShowAtmo = { _ -> /* no-op on home */ }, + onSetShowMini = { v -> scope.launch { store.setShowMiniNowPlaying(v) } }, + onDismiss = { showCustomize = false } + ) + } + if (showAbout) { + AboutDialog(onDismiss = { showAbout = false }) + } + // v2.5.19 — Tour-Sortier-Dialog (verschoben von der einzelnen Touransicht + // in die Audio-Bibliothek). Wiederverwendung der bestehenden + // TourReorderDialog-Komponente aus WaypointListScreen (jetzt internal). + if (showTourReorder) { + TourReorderDialog( + tours = tourList, + onMove = { from, to -> viewModel.moveTour(from, to) }, + onDismiss = { showTourReorder = false } + ) + } + + // Versions-Hinweis (einmal pro versionCode) + VersionNoticeDialogHost() +} + +/* ───────────────────────────── Hero / Quick actions ───────────────────────── */ + +@Composable +private fun HeroQuickActions( + tourCount: Int, + waypointCount: Int, + onOpenExplorer: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Deine Audio-Touren", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "$tourCount Touren · $waypointCount Wegpunkte", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) + ) + } + FilledTonalIconButton( + onClick = onOpenExplorer, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Filled.Map, contentDescription = "Explorer") + } + } + } +} + +/* ───────────────────────────── Tour cover card ───────────────────────────── */ + +@Composable +private fun TourCoverCard( + tourName: String, + waypointCount: Int, + hasRoute: Boolean, + isActive: Boolean, + isSelected: Boolean, + cover: TourCover? = null, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val accent = generatedTourAccent(tourName) + val coverEndColor = Color(0xFF0D1B2A) + val onColor = MaterialTheme.colorScheme.onSurface + // v2.5.7 — Bild-Cover lädt einmalig per remember(path) via Helfer in + // WaypointListScreen (interne sichtbar im selben UI-Paket). + val coverImage = rememberTourCoverBitmap(cover?.imageUri) + // v2.5.7 — Cover-Variante aus persistiertem TourCover ableiten: + // 1) wenn Bild vorhanden → Bild + // 2) sonst wenn Palette/Icon explizit gewählt → diese + // 3) sonst Fallback wie bisher (generierter Akzent + Heuristik-Icon) + val (coverStart, coverEnd) = if (cover != null) { + homeTourCoverPaletteColors(cover.palette) + } else { + accent.copy(alpha = 0.95f) to coverEndColor + } + val coverIcon = cover?.let { homeTourCoverIconVector(it.icon) } ?: pickTourIcon(tourName) + + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = if (isSelected) BorderStroke( + width = 1.5.dp, + color = MaterialTheme.colorScheme.primary + ) else null + ) { + Column { + // Cover-Block (Top): Bild ODER generierter Verlauf + Icon + Box( + modifier = Modifier + .fillMaxWidth() + .height(110.dp) + .clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)) + .drawWithCache { + val brush = Brush.linearGradient( + colors = listOf(coverStart, coverEnd), + start = Offset(0f, 0f), + end = Offset(size.width, size.height) + ) + onDrawBehind { + drawRect(brush = brush, size = Size(size.width, size.height)) + // dezente Wellenlinien nur, wenn kein Bild-Cover dominiert + val path = Path().apply { + moveTo(0f, size.height * 0.68f) + cubicTo( + size.width * 0.25f, size.height * 0.55f, + size.width * 0.55f, size.height * 0.85f, + size.width, size.height * 0.6f + ) + } + drawPath( + path = path, + color = Color.White.copy(alpha = 0.18f), + style = Stroke(width = 2.dp.toPx()) + ) + val path2 = Path().apply { + moveTo(0f, size.height * 0.85f) + cubicTo( + size.width * 0.3f, size.height * 0.7f, + size.width * 0.6f, size.height * 1.05f, + size.width, size.height * 0.78f + ) + } + drawPath( + path = path2, + color = Color.White.copy(alpha = 0.10f), + style = Stroke(width = 2.dp.toPx()) + ) + } + }, + contentAlignment = Alignment.Center + ) { + if (coverImage != null) { + // Bild-Cover füllt die ganze Kachel (center-crop). + androidx.compose.foundation.Image( + bitmap = coverImage, + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } else { + Icon( + imageVector = coverIcon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(46.dp) + ) + } + + // Aktiv-Indikator + if (isActive) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + "LIVE", + color = MaterialTheme.colorScheme.onTertiary, + fontWeight = FontWeight.Bold, + fontSize = 9.sp + ) + } + } + } + + // Text-Block + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = tourName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = onColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(2.dp)) + Text( + text = subtitleFor(waypointCount, hasRoute), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Badge( + icon = Icons.Filled.PinDrop, + text = "$waypointCount" + ) + if (hasRoute) { + Badge( + icon = Icons.Filled.Route, + text = "Route", + tint = MaterialTheme.colorScheme.tertiary + ) + } + } + } + } + } +} + +@Composable +private fun Badge( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + tint: Color = MaterialTheme.colorScheme.primary +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(tint.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(12.dp)) + Spacer(Modifier.width(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = tint, + fontWeight = FontWeight.Medium + ) + } +} + +/* ───────────────────────────── Atmo module ────────────────────────────── */ + +@Composable +private fun AtmoModuleCard( + enabled: Boolean, + playing: Boolean, + sourceLabel: String, + selectedTour: String, + onPlayPause: () -> Unit, + onStop: () -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + onConfigure: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Atmo", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = if (enabled) "$sourceLabel · $selectedTour" + else "Deaktiviert · $selectedTour", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IconButton(onClick = onConfigure) { + Icon( + Icons.Filled.Tune, + contentDescription = "Atmo konfigurieren", + tint = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalIconButton( + onClick = onPrevious, + enabled = enabled + ) { Icon(Icons.Filled.SkipPrevious, contentDescription = "Vorheriger Titel") } + FilledIconButton( + onClick = onPlayPause, + enabled = enabled, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (playing) "Pause" else "Atmo abspielen" + ) + } + FilledTonalIconButton( + onClick = onNext, + enabled = enabled + ) { Icon(Icons.Filled.SkipNext, contentDescription = "Nächster Titel") } + FilledTonalIconButton( + onClick = onStop, + enabled = enabled && playing + ) { Icon(Icons.Filled.Stop, contentDescription = "Atmo stoppen") } + Spacer(Modifier.weight(1f)) + if (playing) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + ) + Spacer(Modifier.width(5.dp)) + Text( + "LIVE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +/* ───────────────────────────── PTT module ──────────────────────────────── */ + +@Composable +private fun PttModuleCard( + pttManager: LivePttManager, + onOpenRecordings: () -> Unit +) { + // 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. + LivePttCard( + pttManager = pttManager, + onOpenRecordings = onOpenRecordings, + modifier = Modifier.padding(horizontal = 0.dp, vertical = 6.dp) + ) +} + +/* ───────────────────────────── Mini now-playing ────────────────────────── */ + +@Composable +private fun MiniNowPlayingBar( + atmoPlaying: Boolean, + manualPlaying: Boolean, + manualName: String?, + tourName: String, + onStopAtmo: () -> Unit, + onStopManual: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = when { + manualPlaying && manualName != null -> manualName + atmoPlaying -> "Atmo läuft" + else -> "Wiedergabe aktiv" + }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Tour: $tourName", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + maxLines = 1 + ) + } + if (manualPlaying) { + IconButton(onClick = onStopManual) { + Icon(Icons.Filled.Stop, "Wiedergabe stoppen") + } + } else if (atmoPlaying) { + IconButton(onClick = onStopAtmo) { + Icon(Icons.Filled.Stop, "Atmo stoppen") + } + } + } + } +} + +/* ───────────────────────────── Customize dialog ────────────────────────── */ + +@Composable +private fun CustomizeHomeDialog( + settings: HomeModulesSettings, + canPtt: Boolean = true, + onSetShowPtt: (Boolean) -> Unit, + onSetShowAtmo: (Boolean) -> Unit, + onSetShowMini: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Startseite anpassen") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Wähle, welche Module auf der Hauptseite erscheinen sollen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(6.dp)) + // v2.5.30: Atmo-Modul-Toggle auf der Startseite entfernt. + // Atmo bleibt in der Tour-/WaypointListScreen vollständig verfügbar. + ToggleRow( + label = "PTT-Modul", + description = if (canPtt) "Schnellzugriff auf Live-PTT und Aufnahmen" + else "Profi-Funktion · GPS2Audio Profi-Lizenz erforderlich", + checked = settings.showPtt && canPtt, + enabled = canPtt, + onCheckedChange = { if (canPtt) onSetShowPtt(it) } + ) + ToggleRow( + label = "Now-Playing-Bar", + description = "Zeigt laufende Wiedergabe als kompakte Leiste", + checked = settings.showMiniNowPlaying, + onCheckedChange = onSetShowMini + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Fertig") } + } + ) +} + +@Composable +private fun ToggleRow( + label: String, + description: String, + checked: Boolean, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + description, + style = MaterialTheme.typography.labelSmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } +} + +/* ───────────────────────────── 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 atmoSourceLabel(type: MusicSourceType): String = when (type) { + MusicSourceType.STREAM_URL -> "Stream-Playlist" + MusicSourceType.LOCAL_PLAYLIST -> "Lokale Playlist" +} + +/** + * Deterministische Akzentfarbe basierend auf Tour-Namen, aus der GPS2Audio- + * Teal/Türkis-Familie. Kein Magenta — das bleibt Warn-/Lösch-Aktionen vorbehalten. + */ +private fun generatedTourAccent(tourName: String): Color { + val palette = listOf( + Color(0xFF0B7B82), // Teal 600 + Color(0xFF1C5461), // dark teal + Color(0xFF004F54), // teal 800 + Color(0xFF4DB6AC), // cyan 300 + Color(0xFF26867E), // teal mid + Color(0xFF15838C), // teal alt + Color(0xFF3A8A82), // muted teal + Color(0xFF0E5A66) // deep teal + ) + val hash = abs(tourName.hashCode()) + return palette[hash % palette.size] +} + +/** + * v2.5.7 — Spiegelt die in [WaypointListScreen] verwendete Palette-Zuordnung + * für [TourCoverPalette]. So sehen Home-Cards und Touransicht-Hero gleich aus, + * wenn ein Cover explizit ausgewählt wurde. + */ +private fun homeTourCoverPaletteColors(palette: TourCoverPalette): Pair = when (palette) { + TourCoverPalette.TEAL -> Color(0xFF0B7B82) to Color(0xFF26867E) + TourCoverPalette.OCEAN -> Color(0xFF1E3C72) to Color(0xFF2A5298) + TourCoverPalette.SUNSET -> Color(0xFFFF6E7F) to Color(0xFFBFE9FF) + TourCoverPalette.FOREST -> Color(0xFF1F4037) to Color(0xFF99F2C8) + TourCoverPalette.BERRY -> Color(0xFF7B1FA2) to Color(0xFFE91E63) + TourCoverPalette.DUSK -> Color(0xFF3A1C71) to Color(0xFFD76D77) + TourCoverPalette.SAND -> Color(0xFFCB956D) to Color(0xFFEACDA3) + TourCoverPalette.MIDNIGHT -> Color(0xFF0F2027) to Color(0xFF2C5364) +} + +/** v2.5.7 — Spiegelt die Icon-Mappings aus [WaypointListScreen]. */ +private fun homeTourCoverIconVector(icon: TourCoverIcon): androidx.compose.ui.graphics.vector.ImageVector = when (icon) { + TourCoverIcon.HEADPHONES -> Icons.Filled.Headphones + TourCoverIcon.MUSIC_NOTE -> Icons.Filled.MusicNote + TourCoverIcon.HIKING -> Icons.Filled.Hiking + TourCoverIcon.PARK -> Icons.Filled.Park + TourCoverIcon.CITY -> Icons.Filled.LocationCity + TourCoverIcon.BIKE -> Icons.Filled.DirectionsBike + TourCoverIcon.WAVES -> Icons.Filled.Waves + TourCoverIcon.EXPLORE -> Icons.Filled.Explore + TourCoverIcon.LANDSCAPE -> Icons.Filled.Landscape + TourCoverIcon.CAMPAIGN -> Icons.Filled.Campaign + TourCoverIcon.NIGHTLIFE -> Icons.Filled.Nightlife + TourCoverIcon.THEATER_COMEDY -> Icons.Filled.TheaterComedy +} + +/* Wähle Icon nach einfachem Heuristik-Schlüsselwort im Tour-Namen. */ +private fun pickTourIcon(name: String): androidx.compose.ui.graphics.vector.ImageVector { + val lower = name.lowercase() + return when { + "wald" in lower || "natur" in lower || "park" in lower -> Icons.Filled.Park + "stadt" in lower || "city" in lower || "altstadt" in lower -> Icons.Filled.LocationCity + "berg" in lower || "alpen" in lower || "wander" in lower -> Icons.Filled.Hiking + "rad" in lower || "bike" in lower || "fahrrad" in lower -> Icons.Filled.DirectionsBike + "see" in lower || "fluss" in lower || "wasser" in lower -> Icons.Filled.Waves + "audio" in lower || "musik" in lower -> Icons.Filled.MusicNote + else -> Icons.Filled.Headphones + } +} + diff --git a/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt b/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt index 5a4a33d..720df1a 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt @@ -21,7 +21,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.Refresh @@ -41,7 +42,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -61,30 +63,33 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import de.waypointaudio.R import de.waypointaudio.data.AudioRoutingSettings -import de.waypointaudio.service.AudioDeviceItem +import de.waypointaudio.data.PttRecordingFormat import de.waypointaudio.service.LivePttManager /** * Karte für Live / PTT auf dem Hauptbildschirm. - * Platzierung: zwischen Atmo-Mini-Player und Wegpunkt-Tracks-Überschrift. * - * Features: - * - PTT starten / stoppen (Toggle-Button) - * - Statusanzeige (Bereit / Mikrofon aktiv / Berechtigung fehlt) - * - Echo-Warnung bei fehlendem Headset - * - Button zum Öffnen der Audio-Geräte-Einstellungen - * - Gerätewahl-Kurzanzeige (gewählte Geräte-Namen) + * v2.1.0: + * - Toggle „Live-Ausgabe" und „Aufnahme speichern" (mit Format M4A/AAC). + * - Aufnahme-Indikator (rotes Punkt) im aktiven Zustand. + * - Knopf „Aufnahmen…" öffnet die Bibliothek; Post-Aufnahme-Dialog erscheint + * automatisch nach jeder gespeicherten Aufnahme. + * + * @param pttManager Singleton-Manager + * @param onOpenRecordings Callback um die Aufnahme-Bibliothek zu öffnen */ @Composable fun LivePttCard( pttManager: LivePttManager, + onOpenRecordings: () -> Unit = {}, modifier: Modifier = Modifier ) { val context = LocalContext.current - val pttActive by pttManager.pttActive.collectAsState() - val routing by pttManager.routingSettings.collectAsState() - val inputDevices by pttManager.inputDevices.collectAsState() - val statusMsg by pttManager.statusMessage.collectAsState() + val pttActive by pttManager.pttActive.collectAsState() + val recordingActive by pttManager.recordingActive.collectAsState() + val routing by pttManager.routingSettings.collectAsState() + val inputDevices by pttManager.inputDevices.collectAsState() + val statusMsg by pttManager.statusMessage.collectAsState() var hasMicPermission by remember { mutableStateOf( @@ -106,12 +111,10 @@ fun LivePttCard( } } - // Geräte laden wenn Karte angezeigt wird LaunchedEffect(Unit) { pttManager.refreshDevices() } - // Farbanimation für PTT-Button val pttContainerColor by animateColorAsState( targetValue = if (pttActive) MaterialTheme.colorScheme.error @@ -121,24 +124,26 @@ fun LivePttCard( label = "pttColor" ) - // Anzeige-Name des gewählten Eingabegeräts val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) { if (routing.selectedInputDeviceId == null) null else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName } + // v2.5.1 — Card mit sanften Cornern und größerem Innenabstand für + // einen ruhigeren Look in der GPS2Audio-Farbwelt. Card( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 2.dp), + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { - // ── Kopfzeile: Titel + Geräte-Button ───────────────────────────── + // ── Kopfzeile: Titel + Aufnahmen-Button + Einstellungen-Button ─── Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -158,7 +163,13 @@ fun LivePttCard( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) - // Gerätewahl-Button + IconButton(onClick = onOpenRecordings) { + Icon( + Icons.Filled.LibraryMusic, + contentDescription = stringResource(R.string.ptt_recordings_button), + tint = MaterialTheme.colorScheme.primary + ) + } IconButton( onClick = { pttManager.refreshDevices() @@ -178,13 +189,13 @@ fun LivePttCard( // ── Status-Zeile ────────────────────────────────────────────────── val statusText = when { !hasMicPermission -> stringResource(R.string.ptt_status_permission_missing) + recordingActive -> stringResource(R.string.ptt_recording_status) pttActive -> stringResource(R.string.ptt_status_active) statusMsg != null -> statusMsg!! else -> stringResource(R.string.ptt_status_ready) } Row(verticalAlignment = Alignment.CenterVertically) { - // Status-Indikator (runder Punkt) Box( modifier = Modifier .size(8.dp) @@ -205,9 +216,17 @@ fun LivePttCard( else -> MaterialTheme.colorScheme.onSurfaceVariant } ) + if (recordingActive) { + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Filled.FiberManualRecord, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(12.dp) + ) + } } - // Gewähltes Gerät anzeigen (falls nicht Systemstandard) if (inputDeviceName != null) { Spacer(Modifier.height(4.dp)) Text( @@ -217,7 +236,6 @@ fun LivePttCard( ) } - // Berechtigung verweigert – Hinweis if (showPermissionDeniedHint) { Spacer(Modifier.height(4.dp)) Text( @@ -227,6 +245,19 @@ fun LivePttCard( ) } + // Mode summary (Live / Aufnahme / beides) + Spacer(Modifier.height(4.dp)) + val modeText = when { + routing.liveOutputEnabled && routing.recordingEnabled -> "Live + Aufnahme" + routing.recordingEnabled -> "Nur Aufnahme" + else -> "Nur Live" + } + Text( + text = modeText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + Spacer(Modifier.height(6.dp)) // ── PTT-Hauptbutton ─────────────────────────────────────────────── @@ -257,10 +288,9 @@ fun LivePttCard( ) } - // Echo-Warnung (immer sichtbar wenn kein Headset konfiguriert) val noHeadset = routing.selectedOutputDeviceId == null && routing.selectedInputDeviceId == null - if (noHeadset) { + if (noHeadset && routing.liveOutputEnabled) { Spacer(Modifier.height(6.dp)) Text( text = stringResource(R.string.ptt_echo_warning), @@ -271,9 +301,8 @@ fun LivePttCard( } } - // ── Audio-Geräte-Dialog ─────────────────────────────────────────────────── if (showDeviceDialog) { - AudioDeviceDialog( + PttSettingsDialog( pttManager = pttManager, onDismiss = { showDeviceDialog = false } ) @@ -281,12 +310,12 @@ fun LivePttCard( } // ───────────────────────────────────────────────────────────────────────────── -// Audio-Geräte-Dialog +// PTT-Einstellungen Dialog (Live/Aufnahme + Geräte) // ───────────────────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AudioDeviceDialog( +fun PttSettingsDialog( pttManager: LivePttManager, onDismiss: () -> Unit ) { @@ -294,9 +323,12 @@ fun AudioDeviceDialog( val outputDevices by pttManager.outputDevices.collectAsState() val routing by pttManager.routingSettings.collectAsState() - // Lokale Auswahl-States var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) } var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) } + var liveEnabled by remember(routing) { mutableStateOf(routing.liveOutputEnabled) } + var recordEnabled by remember(routing) { mutableStateOf(routing.recordingEnabled) } + var format by remember(routing) { mutableStateOf(routing.recordingFormat) } + var validationHint by remember { mutableStateOf(null) } var inputExpanded by remember { mutableStateOf(false) } var outputExpanded by remember { mutableStateOf(false) } @@ -314,10 +346,113 @@ fun AudioDeviceDialog( text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + // ── Live / Aufnahme-Modus ───────────────────────────────────── + Text( + text = stringResource(R.string.ptt_settings_section), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) - // ── Refresh-Button ──────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.ptt_live_output_toggle), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.ptt_live_output_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = liveEnabled, + onCheckedChange = { v -> + liveEnabled = v + validationHint = null + if (!v && !recordEnabled) { + liveEnabled = true + validationHint = "Mindestens eine Option aktiv lassen." + } + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.ptt_recording_toggle), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.ptt_recording_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = recordEnabled, + onCheckedChange = { v -> + recordEnabled = v + validationHint = null + if (!v && !liveEnabled) { + recordEnabled = true + validationHint = "Mindestens eine Option aktiv lassen." + } + } + ) + } + + validationHint?.let { hint -> + Text( + text = hint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + + // Format (M4A only, WAV disabled) + if (recordEnabled) { + Text( + text = stringResource(R.string.ptt_format_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = format == PttRecordingFormat.M4A_AAC, + onClick = { format = PttRecordingFormat.M4A_AAC } + ) + Text(stringResource(R.string.ptt_format_m4a)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = false, + onClick = { /* WAV deaktiviert in v2.1.0 */ }, + enabled = false + ) + Text( + stringResource(R.string.ptt_format_wav), + color = MaterialTheme.colorScheme.outline + ) + } + } + + HorizontalDivider() + + // ── Geräteauswahl ───────────────────────────────────────────── TextButton( onClick = { pttManager.refreshDevices() }, modifier = Modifier.align(Alignment.End) @@ -329,7 +464,6 @@ fun AudioDeviceDialog( style = MaterialTheme.typography.labelMedium) } - // ── Eingabegerät (Mikrofon) ─────────────────────────────────── Text( text = stringResource(R.string.audio_input_device_label), style = MaterialTheme.typography.labelMedium, @@ -365,7 +499,6 @@ fun AudioDeviceDialog( expanded = inputExpanded, onDismissRequest = { inputExpanded = false } ) { - // Systemstandard DropdownMenuItem( text = { Text(stringResource(R.string.ptt_device_system_default)) }, onClick = { @@ -387,7 +520,6 @@ fun AudioDeviceDialog( } } - // ── Ausgabegerät (Lautsprecher) ─────────────────────────────── Text( text = stringResource(R.string.audio_output_device_label), style = MaterialTheme.typography.labelMedium, @@ -423,7 +555,6 @@ fun AudioDeviceDialog( expanded = outputExpanded, onDismissRequest = { outputExpanded = false } ) { - // Systemstandard DropdownMenuItem( text = { Text(stringResource(R.string.ptt_device_system_default)) }, onClick = { @@ -445,7 +576,6 @@ fun AudioDeviceDialog( } } - // ── Hinweis zu Geräte-IDs ───────────────────────────────────── HorizontalDivider() Text( text = stringResource(R.string.audio_devices_hint), @@ -459,7 +589,10 @@ fun AudioDeviceDialog( pttManager.saveRoutingSettings( AudioRoutingSettings( selectedInputDeviceId = selectedInputId, - selectedOutputDeviceId = selectedOutputId + selectedOutputDeviceId = selectedOutputId, + liveOutputEnabled = liveEnabled, + recordingEnabled = recordEnabled, + recordingFormat = format ) ) onDismiss() diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt new file mode 100644 index 0000000..54c1d88 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt @@ -0,0 +1,931 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +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.AltRoute +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.LocationCity +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Nightlife +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.LabelOff +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.MapBaseStyle +import de.waypointaudio.data.PoiLayerSettings + +/** + * Daten-Container für die Layer-Schalter eines Kartenscreens. + * + * v2.4.1 — Erweitertes Basis-Stil-Menü: + * - Modern hell, Dunkel, Topografisch, Terrain/3D, Satellit + * v2.5.38 — POI-Layer-Kategorien ergänzt. + */ +data class MapLayerState( + val followLocation: Boolean = true, + val showMarkers: Boolean = true, + val showRadius: Boolean = true, + val showTrack: Boolean = true, + val showAudioLength: Boolean = true, + val baseStyle: MapBaseStyle = MapBaseStyle.MODERN_2D, + val showAudioLines: Boolean = true, + val poiLayers: PoiLayerSettings = PoiLayerSettings(), +) + +/** + * Floating-Button + BottomSheet für Karten-Ebenen. + * + * v2.4.1 — Neue auswählbare Basis-Stile: Modern hell, Dunkel, Topo, Terrain/3D, + * Satellit. Auswahl wird über [MapStyleStore] persistiert. + * v2.5.38 — POI-Layer-Abschnitt (Sehenswürdigkeiten, Infrastruktur, Gastronomie, + * Mobilität) als Chips. Karteninfo-Dialog (Attribution). Kartentyp persistiert. + * v2.5.39 — Sheet-Inhalt scrollbar (verticalScroll), damit POI-Kategorien und + * Karteninfo auch auf kleinen Displays erreichbar sind. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapLayerFab( + state: MapLayerState, + onStateChange: (MapLayerState) -> Unit, + customSatelliteUrl: String = "", + onCustomSatelliteUrlChange: (String) -> Unit = {}, + customTopoUrl: String = "", + onCustomTopoUrlChange: (String) -> Unit = {}, + customTerrainDemUrl: String = "", + onCustomTerrainDemUrlChange: (String) -> Unit = {}, + perspectiveTilt: Int = 35, + onPerspectiveTiltChange: (Int) -> Unit = {}, + onShowRoutingInfo: () -> Unit = {}, + onShowRoutePlanner: () -> Unit = {}, + modifier: Modifier = Modifier +) { + var showSheet by remember { mutableStateOf(false) } + var showStyleHint by remember { mutableStateOf(null) } + var showAttributionDialog by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + Box(modifier = modifier) { + SmallFloatingActionButton( + onClick = { showSheet = true }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Icon( + Icons.Filled.Layers, + contentDescription = stringResource(R.string.map_layers_open) + ) + } + } + + if (showSheet) { + ModalBottomSheet( + onDismissRequest = { showSheet = false }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 600.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Layers, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.map_layers_title), + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + stringResource(R.string.map_layers_base_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + // v2.5.37 — Erste Zeile: Modern, Dunkel, Outdoor + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + BaseStyleChip( + label = stringResource(R.string.map_layers_base_2d), + icon = Icons.Filled.LightMode, + selected = state.baseStyle == MapBaseStyle.MODERN_2D, + modifier = Modifier.weight(1f), + onClick = { + onStateChange(state.copy(baseStyle = MapBaseStyle.MODERN_2D)) + } + ) + BaseStyleChip( + label = stringResource(R.string.map_layers_base_dark), + icon = Icons.Filled.DarkMode, + selected = state.baseStyle == MapBaseStyle.DARK, + modifier = Modifier.weight(1f), + onClick = { + onStateChange(state.copy(baseStyle = MapBaseStyle.DARK)) + } + ) + BaseStyleChip( + label = stringResource(R.string.map_layers_base_outdoor), + icon = Icons.Filled.Landscape, + selected = state.baseStyle == MapBaseStyle.OUTDOOR, + modifier = Modifier.weight(1f), + onClick = { + onStateChange(state.copy(baseStyle = MapBaseStyle.OUTDOOR)) + } + ) + } + // v2.5.37 — Zweite Zeile: 3D Stadt, Klassisch + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + BaseStyleChip( + label = stringResource(R.string.map_layers_base_city3d), + icon = Icons.Filled.LocationCity, + selected = state.baseStyle == MapBaseStyle.CITY_3D, + modifier = Modifier.weight(1f), + onClick = { + onStateChange(state.copy(baseStyle = MapBaseStyle.CITY_3D)) + } + ) + BaseStyleChip( + label = stringResource(R.string.map_layers_base_classic), + icon = Icons.Filled.Map, + selected = state.baseStyle == MapBaseStyle.CLASSIC, + modifier = Modifier.weight(1f), + onClick = { + onStateChange(state.copy(baseStyle = MapBaseStyle.CLASSIC)) + } + ) + // Platzhalter rechts für symmetrisches Layout + Spacer(Modifier.weight(1f)) + } + + // v2.5.37 — Info-Block für Dunkel-Stil + if (state.baseStyle == MapBaseStyle.DARK) { + StyleConfigSurface( + tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.DarkMode, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.map_layers_dark_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + TextButton(onClick = { showStyleHint = MapBaseStyle.DARK }) { + Text(stringResource(R.string.map_layers_satellite_info_button)) + } + } + } + } + + // v2.5.37 — Info-Block für 3D-Stadt-Stil + if (state.baseStyle == MapBaseStyle.CITY_3D) { + StyleConfigSurface( + tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.LocationCity, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.map_layers_city3d_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + TextButton(onClick = { showStyleHint = MapBaseStyle.CITY_3D }) { + Text(stringResource(R.string.map_layers_satellite_info_button)) + } + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.map_layers_terrain_pitch_label) + + ": " + perspectiveTilt + "°", + style = MaterialTheme.typography.labelMedium + ) + Slider( + value = perspectiveTilt.coerceIn(0, 60).toFloat(), + valueRange = 0f..60f, + steps = 11, + onValueChange = { onPerspectiveTiltChange(it.toInt()) } + ) + } + } + + // v2.5.37 — Info-Block für Outdoor-Stil + if (state.baseStyle == MapBaseStyle.OUTDOOR) { + StyleConfigSurface( + tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Landscape, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.map_layers_outdoor_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + TextButton(onClick = { showStyleHint = MapBaseStyle.OUTDOOR }) { + Text(stringResource(R.string.map_layers_satellite_info_button)) + } + } + } + } + + Text( + stringResource(R.string.map_layers_overlays_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + LayerSwitchRow( + icon = Icons.Filled.GpsFixed, + label = stringResource(R.string.map_layers_follow), + checked = state.followLocation, + onCheckedChange = { onStateChange(state.copy(followLocation = it)) } + ) + LayerSwitchRow( + icon = Icons.Filled.PinDrop, + label = stringResource(R.string.map_layers_markers), + checked = state.showMarkers, + onCheckedChange = { onStateChange(state.copy(showMarkers = it)) } + ) + LayerSwitchRow( + icon = Icons.Filled.RadioButtonChecked, + label = stringResource(R.string.map_layers_radius), + checked = state.showRadius, + onCheckedChange = { onStateChange(state.copy(showRadius = it)) } + ) + LayerSwitchRow( + icon = Icons.Filled.MusicNote, + label = stringResource(R.string.map_layers_audio_length), + checked = state.showAudioLength, + onCheckedChange = { onStateChange(state.copy(showAudioLength = it)) } + ) + LayerSwitchRow( + icon = Icons.Filled.Timeline, + label = stringResource(R.string.map_layers_audio_lines), + checked = state.showAudioLines, + onCheckedChange = { onStateChange(state.copy(showAudioLines = it)) } + ) + Text( + stringResource(R.string.map_layers_audio_lines_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + modifier = Modifier.padding(start = 28.dp) + ) + if (state.showAudioLines) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + AudioLineLegendChip( + color = 0xFF66BB6A.toInt(), + label = stringResource(R.string.map_layers_audio_lines_legend_short) + ) + AudioLineLegendChip( + color = 0xFFFFA726.toInt(), + label = stringResource(R.string.map_layers_audio_lines_legend_medium) + ) + AudioLineLegendChip( + color = 0xFFE53935.toInt(), + label = stringResource(R.string.map_layers_audio_lines_legend_long) + ) + } + } + LayerSwitchRow( + icon = Icons.Filled.Timeline, + label = stringResource(R.string.map_layers_track), + checked = state.showTrack, + onCheckedChange = { onStateChange(state.copy(showTrack = it)) } + ) + + // ─── v2.5.38 — POI-Layer-Abschnitt ─────────────────────────── + Spacer(Modifier.height(2.dp)) + Text( + stringResource(R.string.poi_section_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (state.baseStyle == MapBaseStyle.CLASSIC) { + Text( + stringResource(R.string.poi_unavailable_classic), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 4.dp) + ) + } else { + Text( + stringResource(R.string.poi_section_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + modifier = Modifier.padding(start = 4.dp) + ) + // Zeile 1: Sehenswürdigkeiten + Infrastruktur + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + PoiCategoryChip( + label = stringResource(R.string.poi_cat_sights), + icon = Icons.Filled.Place, + selected = state.poiLayers.showSights, + modifier = Modifier.weight(1f), + onClick = { + onStateChange( + state.copy( + poiLayers = state.poiLayers.copy( + showSights = !state.poiLayers.showSights + ) + ) + ) + } + ) + PoiCategoryChip( + label = stringResource(R.string.poi_cat_infrastructure), + icon = Icons.Filled.LocationOn, + selected = state.poiLayers.showInfrastructure, + modifier = Modifier.weight(1f), + onClick = { + onStateChange( + state.copy( + poiLayers = state.poiLayers.copy( + showInfrastructure = !state.poiLayers.showInfrastructure + ) + ) + ) + } + ) + } + // Zeile 2: Gastronomie + Mobilität + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + PoiCategoryChip( + label = stringResource(R.string.poi_cat_gastronomy), + icon = Icons.Filled.Nightlife, + selected = state.poiLayers.showGastronomy, + modifier = Modifier.weight(1f), + onClick = { + onStateChange( + state.copy( + poiLayers = state.poiLayers.copy( + showGastronomy = !state.poiLayers.showGastronomy + ) + ) + ) + } + ) + PoiCategoryChip( + label = stringResource(R.string.poi_cat_mobility), + icon = Icons.Filled.Route, + selected = state.poiLayers.showMobility, + modifier = Modifier.weight(1f), + onClick = { + onStateChange( + state.copy( + poiLayers = state.poiLayers.copy( + showMobility = !state.poiLayers.showMobility + ) + ) + ) + } + ) + } + // v2.5.42 — POI-Namen-Toggle: standardmäßig deaktiviert + val anyPoiActive = state.poiLayers.showSights || + state.poiLayers.showGastronomy || + state.poiLayers.showMobility || + state.poiLayers.showInfrastructure + if (anyPoiActive) { + LayerSwitchRow( + icon = if (state.poiLayers.showPoiNames) + Icons.Filled.Label else Icons.Filled.LabelOff, + label = "POI-Namen anzeigen", + checked = state.poiLayers.showPoiNames, + onCheckedChange = { + onStateChange( + state.copy( + poiLayers = state.poiLayers.copy( + showPoiNames = it + ) + ) + ) + } + ) + } + } + + // ─── v2.5.38 — Karteninfo / Attribution ────────────────────── + Spacer(Modifier.height(2.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.map_info_dialog_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "OpenFreeMap · OpenMapTiles · © OpenStreetMap-Mitwirkende", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f) + ) + } + TextButton(onClick = { showAttributionDialog = true }) { + Text(stringResource(R.string.map_info_open)) + } + } + } + + // v2.4.2 — Routenplaner-Eintrag im Layer-Panel. + 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.AltRoute, + 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), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + stringResource(R.string.route_planner_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f) + ) + } + TextButton(onClick = onShowRoutePlanner) { + Text(stringResource(R.string.route_planner_open)) + } + } + } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.routing_poi_open), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + stringResource(R.string.routing_poi_search_attribution), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } + TextButton(onClick = onShowRoutingInfo) { + Text(stringResource(R.string.routing_poi_open)) + } + } + } + + TextButton( + onClick = { showSheet = false }, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.map_layers_close)) + } + } + } + } + + // v2.5.38 — Stil-Info-Dialog (unverändert aus v2.5.37) + showStyleHint?.let { style -> + val title: String + val body: String + when (style) { + MapBaseStyle.DARK -> { + title = stringResource(R.string.map_layers_base_dark) + body = stringResource(R.string.map_layers_dark_info) + } + MapBaseStyle.OUTDOOR -> { + title = stringResource(R.string.map_layers_base_outdoor) + body = stringResource(R.string.map_layers_outdoor_info) + } + MapBaseStyle.CITY_3D -> { + title = stringResource(R.string.map_layers_base_city3d) + body = stringResource(R.string.map_layers_city3d_info) + } + MapBaseStyle.CLASSIC -> { + title = stringResource(R.string.map_layers_base_classic) + body = stringResource(R.string.map_layers_classic_info) + } + else -> { + title = stringResource(R.string.map_layers_base_2d) + body = stringResource(R.string.map_layers_modern_info) + } + } + AlertDialog( + onDismissRequest = { showStyleHint = null }, + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { + TextButton(onClick = { showStyleHint = null }) { + Text(stringResource(R.string.version_notice_ok)) + } + } + ) + } + + // v2.5.38 — Karteninfo / Attribution-Dialog + if (showAttributionDialog) { + MapAttributionDialog(onDismiss = { showAttributionDialog = false }) + } +} + +/** + * v2.5.38 — Vollständiger Karteninfo-/Attribution-Dialog. + * + * Zeigt rechtlich korrekte Namensnennung für: + * - OpenFreeMap (openfreemap.org) + * - OpenMapTiles (openmaptiles.org) + * - OpenStreetMap-Mitwirkende (ODbL) + * + * Links werden als anklickbare Texte dargestellt. + */ +@Composable +fun MapAttributionDialog(onDismiss: () -> Unit) { + val uriHandler = LocalUriHandler.current + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.map_info_dialog_title)) + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Kartenstil + Text( + stringResource(R.string.map_info_style_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + stringResource(R.string.map_info_style_source), + style = MaterialTheme.typography.bodyMedium + ) + + // Kartendaten + Text( + stringResource(R.string.map_info_data_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + stringResource(R.string.map_info_data_text), + style = MaterialTheme.typography.bodyMedium + ) + + // Links + Text( + stringResource(R.string.map_info_links_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // openfreemap.org + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), + modifier = Modifier + .fillMaxWidth() + .clickable { + runCatching { + uriHandler.openUri("https://openfreemap.org") + } + } + ) { + Text( + "↗ " + stringResource(R.string.map_info_link_ofm), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + + // openmaptiles.org + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), + modifier = Modifier + .fillMaxWidth() + .clickable { + runCatching { + uriHandler.openUri("https://openmaptiles.org") + } + } + ) { + Text( + "↗ " + stringResource(R.string.map_info_link_omt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + + // openstreetmap.org/copyright + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), + modifier = Modifier + .fillMaxWidth() + .clickable { + runCatching { + uriHandler.openUri("https://www.openstreetmap.org/copyright") + } + } + ) { + Text( + "↗ " + stringResource(R.string.map_info_link_osm), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + + Spacer(Modifier.height(2.dp)) + Text( + "Lizenz: ODbL (Open Database License). Namensnennung erforderlich.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.map_info_close)) + } + } + ) +} + +@Composable +private fun StyleConfigSurface( + tone: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = tone, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + content = content + ) + } +} + +@Composable +private fun BaseStyleChip( + label: String, + icon: ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val container = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + } + val content = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + shape = RoundedCornerShape(14.dp), + color = container, + modifier = modifier.clickable(onClick = onClick) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 6.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = content) + Spacer(Modifier.height(4.dp)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = content + ) + } + } +} + +/** + * v2.5.38 — POI-Kategorie-Chip (kompaktes Toggle-Chip-Design). + */ +@Composable +private fun PoiCategoryChip( + label: String, + icon: ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val container = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + } + val content = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + shape = RoundedCornerShape(12.dp), + color = container, + modifier = modifier.clickable(onClick = onClick) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = content) + Spacer(Modifier.width(4.dp)) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = content + ) + } + } +} + +@Composable +private fun AudioLineLegendChip(color: Int, label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(2.dp), + color = androidx.compose.ui.graphics.Color(color), + modifier = Modifier + .width(14.dp) + .height(4.dp) + ) {} + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun LayerSwitchRow( + icon: ImageVector, + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + ) + Spacer(Modifier.width(10.dp)) + Text( + label, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium + ) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapMode.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapMode.kt new file mode 100644 index 0000000..e0d8e7d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapMode.kt @@ -0,0 +1,128 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import androidx.compose.foundation.clickable + +/** + * Modi des vereinten Kartenmoduls (v2.2.1). + * + * EXPLORER — Live-Standort + read-only Wegpunkte (kein PTT, kein Atmo). + * EDITOR — Bestehender Karteneditor (Long-Press neue Wegpunkte, Track-Aufzeichnung). + * + * ROUTE/IMPORT sind v2.2.1 noch nicht eigenständige Modi — Import/Drafts laufen + * weiterhin über das Overflow-Menü bzw. den Karteneditor. + */ +enum class MapMode { EXPLORER, EDITOR } + +/** + * Kompakte Segmented-Control für die TopAppBar. + * Wird im Title-Slot der jeweiligen Map/Explorer-TopAppBar gerendert, damit die + * gemeinsame Visualisierung „eine Karte mit Modusumschalter" entsteht. + */ +@Composable +fun MapModeSegmentedHeader( + current: MapMode, + onChange: (MapMode) -> Unit, + subtitle: String? = null, + modifier: Modifier = Modifier, + contentColor: androidx.compose.ui.graphics.Color? = null +) { + val on = contentColor ?: MaterialTheme.colorScheme.onPrimary + val container = on.copy(alpha = 0.14f) + val selectedContainer = on.copy(alpha = 0.28f) + + androidx.compose.foundation.layout.Column(modifier = modifier) { + Surface( + shape = RoundedCornerShape(20.dp), + color = container + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Segment( + label = stringResource(R.string.map_mode_explorer), + icon = Icons.Filled.Explore, + selected = current == MapMode.EXPLORER, + selectedColor = selectedContainer, + contentColor = on, + onClick = { if (current != MapMode.EXPLORER) onChange(MapMode.EXPLORER) } + ) + Segment( + label = stringResource(R.string.map_mode_editor), + icon = Icons.Filled.Edit, + selected = current == MapMode.EDITOR, + selectedColor = selectedContainer, + contentColor = on, + onClick = { if (current != MapMode.EDITOR) onChange(MapMode.EDITOR) } + ) + } + } + if (subtitle != null) { + Spacer(Modifier.height(1.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = on.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun Segment( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + selected: Boolean, + selectedColor: androidx.compose.ui.graphics.Color, + contentColor: androidx.compose.ui.graphics.Color, + onClick: () -> Unit +) { + Surface( + shape = RoundedCornerShape(16.dp), + color = if (selected) selectedColor else androidx.compose.ui.graphics.Color.Transparent, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = contentColor + ) + Spacer(Modifier.width(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapProviderDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapProviderDialog.kt new file mode 100644 index 0000000..c8013fe --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapProviderDialog.kt @@ -0,0 +1,66 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.MapProvider + +/** + * Auswahl-Dialog für den Karten-Tile-Provider. + * + * Listet alle Provider aus [MapProvider]. Die OSM-Attribution bleibt für alle + * Provider sichtbar (eigenes Overlay in MapScreen). + */ +@Composable +fun MapProviderDialog( + selected: MapProvider, + onSelect: (MapProvider) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.map_provider_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + MapProvider.values().forEach { provider -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = (provider == selected), + onClick = { onSelect(provider) } + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (provider == selected), + onClick = { onSelect(provider) } + ) + Text( + stringResource(provider.displayNameRes), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.about_close)) } + } + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt index db6673f..f52fa80 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt @@ -29,8 +29,12 @@ import androidx.compose.material.icons.filled.AddLocation import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Timeline import androidx.compose.material3.AlertDialog @@ -72,13 +76,14 @@ import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource import de.waypointaudio.R import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.MapProvider +import de.waypointaudio.data.MapProviderStore import de.waypointaudio.data.Waypoint import de.waypointaudio.viewmodel.WaypointViewModel import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver -import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay @@ -116,7 +121,12 @@ import kotlin.math.sqrt @Composable fun MapScreen( viewModel: WaypointViewModel, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, + // v2.2.1: Wenn gesetzt, zeigt die TopAppBar einen Explorer/Editor-Modus- + // Umschalter im Titel-Slot. Dadurch wirken Explorer und Editor als ein + // gemeinsames Kartenmodul. + currentMode: MapMode? = null, + onModeChange: ((MapMode) -> Unit)? = null ) { val allWaypoints by viewModel.waypoints.collectAsState() val selectedTour by viewModel.selectedTour.collectAsState() @@ -149,6 +159,14 @@ fun MapScreen( var currentLocation by remember { mutableStateOf?>(null) } var showAddWaypointAction by remember { mutableStateOf(false) } + // Karten-Anbieter (Phase 1): persistent via DataStore, app-weit gespeichert. + // Hinweis: Eine Pro-Tour-Auswahl ist mit der bisherigen Tour-Settings-Architektur + // nicht direkt vereinbar (Touren halten Audio-Einstellungen, keine Karten-Settings), + // daher bewusst app-weit. Die Auswahl ist über das Karten-Menü jederzeit umstellbar. + val providerStore = remember { MapProviderStore(context.applicationContext) } + val selectedProvider by providerStore.provider.collectAsState(initial = MapProvider.DEFAULT) + var showProviderDialog by remember { mutableStateOf(false) } + // Editor-Dialog-State var editDialogWaypoint by remember { mutableStateOf(null) } var editDialogPrefill by remember { mutableStateOf?>(null) } @@ -167,6 +185,15 @@ fun MapScreen( // Bestätigungsdialog für das Löschen eines Wegpunkts var deleteConfirmWaypoint by remember { mutableStateOf(null) } + // Ortssuche (Nominatim/OpenStreetMap) — temporärer roter Pin auf der Karte. + // Verändert weder Wegpunkte, Tracks, Drafts noch den Aufnahmezustand. + var showSearchDialog by remember { mutableStateOf(false) } + var searchPin by remember { mutableStateOf(null) } + + // v2.2.1: Layer-Panel-Status. Die Switches steuern aktuell vorwiegend + // Sichtbarkeit; markers/radius/track werden in drawMapOverlays gefiltert. + var layerState by remember { mutableStateOf(MapLayerState()) } + // Bitmap für Standort-Puck (einmalig erstellt) val puckBitmap = remember { createLocationPuckBitmap(context) } @@ -174,17 +201,27 @@ fun MapScreen( LocationServices.getFusedLocationProviderClient(context) } - // ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ──────── - LaunchedEffect(waypoints, primaryColor, currentTrack) { + // ─── Karten-Anbieter Wechsel: Tile-Source aktualisieren und Cache invalidieren ─ + LaunchedEffect(selectedProvider) { + val mv = mapView ?: return@LaunchedEffect + mv.setTileSource(selectedProvider.toTileSource()) + mv.invalidate() + } + + // ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben / Auswahl / Such-Pin / Layer sich ändern ─ + LaunchedEffect(waypoints, primaryColor, currentTrack, actionMenuWaypoint, searchPin, layerState) { val mv = mapView ?: return@LaunchedEffect drawMapOverlays( mv = mv, - waypoints = waypoints, - trackPoints = currentTrack, + waypoints = if (layerState.showMarkers) waypoints else emptyList(), + trackPoints = if (layerState.showTrack) currentTrack else emptyList(), primaryArgb = primaryColor.toArgb(), errorArgb = errorColor.toArgb(), trackArgb = trackColor.toArgb(), - onWaypointTapped = { wp -> actionMenuWaypoint = wp } + selectedWaypointId = actionMenuWaypoint?.id, + onWaypointTapped = { wp -> actionMenuWaypoint = wp }, + searchPin = searchPin, + showRadius = layerState.showRadius ) } @@ -223,6 +260,10 @@ fun MapScreen( showEditDialog = false editDialogWaypoint = null editDialogPrefill = null + }, + // v2.5.16 — Kopieren auch aus dem Karten-Bearbeiten-Flow möglich. + onCopyToOtherTour = editDialogWaypoint?.let { wp -> + { targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) } } ) } @@ -350,18 +391,54 @@ fun MapScreen( ) } + // ─── Karten-Anbieter Auswahl-Dialog ───────────────────────────────────── + if (showProviderDialog) { + MapProviderDialog( + selected = selectedProvider, + onSelect = { provider -> + scope.launch { + providerStore.setProvider(provider) + } + showProviderDialog = false + }, + onDismiss = { showProviderDialog = false } + ) + } + + // ─── Ortssuche Dialog (Nominatim/OSM) ──────────────────────────────────── + if (showSearchDialog) { + PlaceSearchDialog( + onDismiss = { showSearchDialog = false }, + onResultSelected = { result -> + val gp = GeoPoint(result.latitude, result.longitude) + searchPin = gp + mapView?.controller?.animateTo(gp) + mapView?.controller?.setZoom(15.0) + showSearchDialog = false + } + ) + } + Scaffold( topBar = { TopAppBar( title = { - Column { - Text(stringResource(R.string.map_view)) - if (tourList.size > 1) { - Text( - text = selectedTour, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) - ) + if (currentMode != null && onModeChange != null) { + MapModeSegmentedHeader( + current = currentMode, + onChange = onModeChange, + subtitle = if (tourList.size > 1) selectedTour else null + ) + } else { + Column { + Text(stringResource(R.string.map_view)) + if (tourList.size > 1) { + Text( + text = selectedTour, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + ) + } } } }, @@ -373,10 +450,33 @@ fun MapScreen( ) } }, + actions = { + IconButton(onClick = { showProviderDialog = true }) { + Icon( + Icons.Filled.Layers, + contentDescription = stringResource(R.string.map_provider_menu) + ) + } + IconButton(onClick = { showSearchDialog = true }) { + Icon( + Icons.Filled.Search, + contentDescription = stringResource(R.string.search_place_action) + ) + } + if (searchPin != null) { + IconButton(onClick = { searchPin = null }) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.search_place_close) + ) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.onPrimary, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimary + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary ) ) }, @@ -518,7 +618,7 @@ fun MapScreen( modifier = Modifier.fillMaxSize(), factory = { ctx -> MapView(ctx).apply { - setTileSource(TileSourceFactory.MAPNIK) + setTileSource(selectedProvider.toTileSource()) setMultiTouchControls(true) controller.setZoom(12.0) @@ -557,12 +657,15 @@ fun MapScreen( mapView = this drawMapOverlays( mv = this, - waypoints = waypoints, - trackPoints = currentTrack, + waypoints = if (layerState.showMarkers) waypoints else emptyList(), + trackPoints = if (layerState.showTrack) currentTrack else emptyList(), primaryArgb = primaryColor.toArgb(), errorArgb = errorColor.toArgb(), trackArgb = trackColor.toArgb(), - onWaypointTapped = { wp -> actionMenuWaypoint = wp } + selectedWaypointId = actionMenuWaypoint?.id, + onWaypointTapped = { wp -> actionMenuWaypoint = wp }, + searchPin = searchPin, + showRadius = layerState.showRadius ) } }, @@ -570,12 +673,15 @@ fun MapScreen( mapView = mv drawMapOverlays( mv = mv, - waypoints = waypoints, - trackPoints = currentTrack, + waypoints = if (layerState.showMarkers) waypoints else emptyList(), + trackPoints = if (layerState.showTrack) currentTrack else emptyList(), primaryArgb = primaryColor.toArgb(), errorArgb = errorColor.toArgb(), trackArgb = trackColor.toArgb(), - onWaypointTapped = { wp -> actionMenuWaypoint = wp } + selectedWaypointId = actionMenuWaypoint?.id, + onWaypointTapped = { wp -> actionMenuWaypoint = wp }, + searchPin = searchPin, + showRadius = layerState.showRadius ) } ) @@ -618,11 +724,12 @@ fun MapScreen( } // ─── Track-Statistik-Panel ─────────────────────────────────────── + // Bottom padding leaves a clear strip for the OSM attribution badge. AnimatedVisibility( visible = isRecording || currentTrack.isNotEmpty(), modifier = Modifier .align(Alignment.BottomStart) - .padding(start = 12.dp, bottom = 12.dp), + .padding(start = 12.dp, bottom = 40.dp), enter = fadeIn() + slideInVertically(initialOffsetY = { it }), exit = fadeOut() + slideOutVertically(targetOffsetY = { it }) ) { @@ -710,11 +817,12 @@ fun MapScreen( } // ─── Editor-Hinweis (unten mittig) ────────────────────────────── + // Raised above the OSM attribution strip so the mandatory badge is never covered. AnimatedVisibility( visible = !isRecording && currentTrack.isEmpty(), modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 16.dp), + .padding(bottom = 40.dp), enter = fadeIn(), exit = fadeOut() ) { @@ -731,6 +839,26 @@ fun MapScreen( ) } } + + // ─── Layer-/3D-Overlay-FAB (v2.2.1) ────────────────────────────── + // Schwebt rechts oben über der Karte, öffnet das Material-3-Panel + // mit Layer-Switches und 3D-Vorbereitungs-Hinweis. + MapLayerFab( + state = layerState, + onStateChange = { layerState = it }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + ) + + // ─── OSM-Attribution (Pflicht) ─────────────────────────────────── + // Moved to bottom-left so the FAB stack at bottom-right cannot overlap it. + // The track-stats card and editor hint above leave a clear strip for this badge. + OsmAttributionBadge( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 6.dp) + ) } } @@ -752,42 +880,73 @@ private fun drawMapOverlays( primaryArgb: Int, errorArgb: Int, trackArgb: Int, - onWaypointTapped: (Waypoint) -> Unit + selectedWaypointId: String?, + onWaypointTapped: (Waypoint) -> Unit, + searchPin: GeoPoint? = null, + showRadius: Boolean = true ) { val overlays = mv.overlays - // Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen + // Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen. + // Mein-Standort-Puck überlebt (eigener Marker mit title="Mein Standort"). overlays.removeAll(overlays.filterIsInstance().toSet()) val waypointMarkers = overlays.filterIsInstance() .filter { it.title != "Mein Standort" } overlays.removeAll(waypointMarkers.toSet()) overlays.removeAll(overlays.filterIsInstance().toSet()) - // GPS-Track zeichnen + val density = mv.resources.displayMetrics.density + + // GPS-Track zeichnen — kräftiger, mit weißem Halo für Kontrast auf jedem Untergrund. if (trackPoints.size >= 2) { + val pts = trackPoints.map { GeoPoint(it.latitude, it.longitude) } + // Halo (heller Rand) + val halo = Polyline(mv).apply { + setPoints(pts) + outlinePaint.color = 0xFFFFFFFF.toInt() + outlinePaint.strokeWidth = 18f * density / 2f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 230 + } + overlays.add(halo) + // Hauptlinie val polyline = Polyline(mv).apply { - setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) }) + setPoints(pts) outlinePaint.color = trackArgb - outlinePaint.strokeWidth = 6f - outlinePaint.alpha = 200 + outlinePaint.strokeWidth = 12f * density / 2f + outlinePaint.strokeCap = Paint.Cap.ROUND + outlinePaint.strokeJoin = Paint.Join.ROUND + outlinePaint.alpha = 235 } overlays.add(polyline) } - // Wegpunkte zeichnen + // Wegpunkte zeichnen — größere, gut sichtbare Streckenpunkt-Handles for (wp in waypoints) { val geoPoint = GeoPoint(wp.latitude, wp.longitude) - // Radius-Kreis - val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb) - overlays.add(circle) + // Radius-Kreis (nur wenn Layer aktiv) + if (showRadius) { + val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb) + overlays.add(circle) + } - // Marker mit Tap-Handler + val isSelected = selectedWaypointId != null && wp.id == selectedWaypointId + // Größerer Marker mit Kontrast-Halo. Ausgewählter Punkt: andere Farbe + größer. + val handleBitmap = createWaypointHandleBitmap( + context = mv.context, + fillArgb = if (isSelected) errorArgb else primaryArgb, + isSelected = isSelected + ) val marker = Marker(mv).apply { position = geoPoint title = wp.name snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt()) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - alpha = if (wp.isActive) 1.0f else 0.4f + // ANCHOR_CENTER, ANCHOR_CENTER → Touch-Trefferfläche entspricht der Bitmap-Größe und + // sitzt zentriert über dem Punkt (deutlich größer als der frühere Default-Pin). + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + alpha = if (wp.isActive) 1.0f else 0.55f + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, handleBitmap) // infoWindow = null → Tap löst onMarkerClick aus setOnMarkerClickListener { _, _ -> onWaypointTapped(wp) @@ -797,9 +956,79 @@ private fun drawMapOverlays( overlays.add(marker) } + // Temporärer roter Such-Treffer-Pin (Nominatim/OSM). Verändert keine Daten. + if (searchPin != null) { + val pinBm = makeSearchPinBitmapShared(96) + val pinMarker = Marker(mv).apply { + position = searchPin + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm) + title = null + snippet = null + subDescription = null + infoWindow = null + setOnMarkerClickListener { _, _ -> true } + } + overlays.add(pinMarker) + } + mv.invalidate() } +/** + * Zeichnet einen großen, kontraststarken Kreis-Handle für einen Streckenpunkt. + * Die Bitmap-Größe bestimmt zugleich die Touch-Trefferfläche des osmdroid-Markers. + * - Normaler Punkt: ca. 44dp Trefferfläche, sichtbarer Kreis ~26dp mit weißem Halo + * - Ausgewählter Punkt: ca. 56dp Trefferfläche, sichtbarer Kreis ~36dp mit dunklem Außenrand + */ +private fun createWaypointHandleBitmap( + context: android.content.Context, + fillArgb: Int, + isSelected: Boolean +): Bitmap { + val density = context.resources.displayMetrics.density + // Touch-Trefferfläche (Bitmap-Kantenlänge) – mind. 44dp/56dp lt. Material Touch-Targets + val touchDp = if (isSelected) 56f else 44f + val sizePx = (touchDp * density).toInt() + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val cx = sizePx / 2f + val cy = sizePx / 2f + + // Sichtbarer Kreis-Radius + val visibleRadiusDp = if (isSelected) 18f else 13f + val visibleRadius = visibleRadiusDp * density + val haloThicknessDp = if (isSelected) 4.5f else 3.5f + val haloThickness = haloThicknessDp * density + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Weicher Schatten-/Halo-Ring (weiß), damit der Punkt auf jedem Kartenuntergrund sichtbar bleibt + paint.style = Paint.Style.FILL + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius + haloThickness, paint) + + // Füllung + paint.color = fillArgb + canvas.drawCircle(cx, cy, visibleRadius, paint) + + // Kontrast-Außenrand (dunkel) – beim ausgewählten Punkt deutlich kräftiger + paint.style = Paint.Style.STROKE + paint.strokeWidth = (if (isSelected) 3f else 2f) * density + paint.color = 0xFF202020.toInt() + canvas.drawCircle(cx, cy, visibleRadius, paint) + + if (isSelected) { + // Innerer heller Ring als zusätzliche „Selected"-Markierung + paint.style = Paint.Style.STROKE + paint.strokeWidth = 2f * density + paint.color = 0xFFFFFFFF.toInt() + canvas.drawCircle(cx, cy, visibleRadius - 4f * density, paint) + } + + return bmp +} + // --------------------------------------------------------------------------- // Hilfsfunktionen: Standort-Puck, Marker, Radius-Kreis, Distanz // --------------------------------------------------------------------------- diff --git a/app/src/main/kotlin/de/waypointaudio/ui/PlaceSearchDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/PlaceSearchDialog.kt new file mode 100644 index 0000000..ff558ab --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/PlaceSearchDialog.kt @@ -0,0 +1,229 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.util.NominatimClient +import de.waypointaudio.util.NominatimResult +import kotlinx.coroutines.launch + +/** + * Modaler Dialog: Ortssuche via Nominatim/OpenStreetMap. + * Wird sowohl im normalen Karten-Screen ([MapScreen]) als auch im + * [TrackDraftEditorScreen] verwendet. + * + * Hinweise: + * - Reuse über alle Map-Screens hinweg, damit Verhalten/Texte konsistent bleiben. + * - HTTP-Fehler werden als generische Fehlermeldung angezeigt. + * - Das Auswählen eines Ergebnisses ruft [onResultSelected] auf — der Aufrufer + * entscheidet, was mit dem Treffer passiert (z.B. Kartenzentrum + roter Pin). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaceSearchDialog( + onDismiss: () -> Unit, + onResultSelected: (NominatimResult) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var query by remember { mutableStateOf("") } + var loading by remember { mutableStateOf(false) } + var results by remember { mutableStateOf>(emptyList()) } + var hasSearched by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + fun runSearch() { + if (query.isBlank() || loading) return + loading = true + errorMessage = null + hasSearched = true + scope.launch { + val outcome = runCatching { NominatimClient.search(context, query) } + loading = false + outcome.onSuccess { list -> + results = list + errorMessage = null + }.onFailure { _ -> + results = emptyList() + errorMessage = context.getString(R.string.search_place_error) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.search_place_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(R.string.search_place_query_label)) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + Button( + onClick = { runSearch() }, + enabled = query.isNotBlank() && !loading + ) { + Text(stringResource(R.string.search_place_button)) + } + } + + Text( + stringResource(R.string.search_place_attribution), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + HorizontalDivider() + + when { + loading -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text( + stringResource(R.string.search_place_loading), + style = MaterialTheme.typography.bodySmall + ) + } + } + errorMessage != null -> { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + hasSearched && results.isEmpty() -> { + Text( + stringResource(R.string.search_place_no_results), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + results.isNotEmpty() -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + ) { + items(results) { r -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { onResultSelected(r) } + ) { + Text( + r.displayName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.search_place_close)) + } + } + ) +} + +/** + * Erzeugt eine rote Pin-Bitmap für temporäre Such-Treffer-Marker auf der Karte. + * Wird sowohl in [MapScreen] als auch in [TrackDraftEditorScreen] verwendet. + */ +internal fun makeSearchPinBitmapShared(sizePx: Int): android.graphics.Bitmap { + val size = sizePx.coerceAtLeast(24) + val bm = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bm) + val cx = size / 2f + val cyTop = size * 0.36f + val r = size * 0.30f + + val shadow = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0x66000000.toInt() + } + canvas.drawCircle(cx, size - size * 0.08f, size * 0.10f, shadow) + + val tail = android.graphics.Path().apply { + moveTo(cx - r * 0.55f, cyTop + r * 0.55f) + lineTo(cx + r * 0.55f, cyTop + r * 0.55f) + lineTo(cx, size - size * 0.05f) + close() + } + val body = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFFD32F2F.toInt() + } + canvas.drawPath(tail, body) + canvas.drawCircle(cx, cyTop, r, body) + + val ring = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.STROKE + color = 0xFFFFFFFF.toInt() + strokeWidth = (size * 0.06f).coerceAtLeast(2f) + } + canvas.drawCircle(cx, cyTop, r, ring) + + val dot = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + } + canvas.drawCircle(cx, cyTop, r * 0.32f, dot) + + return bm +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt b/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt new file mode 100644 index 0000000..b0c72ee --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/PttRecordingDialogs.kt @@ -0,0 +1,683 @@ +package de.waypointaudio.ui + +import android.media.MediaPlayer +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddLocationAlt +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Assignment +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import de.waypointaudio.R +import de.waypointaudio.data.PttRecording +import de.waypointaudio.data.Waypoint +import de.waypointaudio.service.LivePttManager +import de.waypointaudio.viewmodel.WaypointViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private fun formatDuration(ms: Long): String { + if (ms <= 0L) return "–" + val totalSec = ms / 1000 + val mm = totalSec / 60 + val ss = totalSec % 60 + return "%d:%02d".format(mm, ss) +} + +private fun formatTimestamp(ms: Long): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.GERMANY).format(Date(ms)) +} + +/** + * Lokaler Preview-Player für die PTT-Bibliothek (UI-isoliert). + * + * Bewusst NICHT beim WaypointAudioInterruptionCoordinator registriert: die + * Wiedergabe ist eine reine Voransicht innerhalb des Dialogs und endet + * spätestens beim Schließen des Dialogs/Wechsel der Aufnahme. So bleibt + * der bestehende Atmo-/PTT-/Waypoint-Audio-Pfad unverändert. + */ +private class RecordingPreviewPlayer { + private var mediaPlayer: MediaPlayer? = null + var playingId: String? = null + private set + + fun isPlaying(id: String): Boolean = + playingId == id && mediaPlayer?.isPlaying == true + + fun toggle( + context: android.content.Context, + recording: PttRecording, + onCompletion: () -> Unit + ) { + val mp = mediaPlayer + if (playingId == recording.id && mp != null) { + runCatching { + if (mp.isPlaying) mp.pause() else mp.start() + }.onFailure { + release() + load(context, recording, onCompletion) + } + return + } + release() + load(context, recording, onCompletion) + } + + private fun load( + context: android.content.Context, + recording: PttRecording, + onCompletion: () -> Unit + ) { + runCatching { + mediaPlayer = MediaPlayer().apply { + setDataSource(context, Uri.parse(recording.uri)) + prepare() + start() + setOnCompletionListener { + release() + onCompletion() + } + setOnErrorListener { mp, _, _ -> + runCatching { mp.release() } + release() + onCompletion() + true + } + } + playingId = recording.id + }.onFailure { + release() + } + } + + fun release() { + runCatching { + mediaPlayer?.let { + if (it.isPlaying) it.stop() + it.release() + } + } + mediaPlayer = null + playingId = null + } +} + +/** + * Post-Aufnahme-Dialog. Erscheint automatisch nach jeder gespeicherten PTT-Aufnahme. + * Buttons: „Als Waypoint-Audio zuweisen", „OK", „Löschen". + */ +@Composable +fun PostRecordingDialog( + recording: PttRecording, + viewModel: WaypointViewModel, + onAssign: (PttRecording) -> Unit, + onDelete: (PttRecording) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + Icons.Filled.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { Text(stringResource(R.string.ptt_recording_saved_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.ptt_recording_saved_name, recording.displayName)) + Text(stringResource(R.string.ptt_recording_saved_duration, formatDuration(recording.durationMs))) + if (recording.latitude != null && recording.longitude != null) { + Text(stringResource( + R.string.ptt_recording_saved_gps, + recording.latitude, + recording.longitude + )) + } + } + }, + confirmButton = { + Button(onClick = { onAssign(recording) }) { + Icon(Icons.Filled.Assignment, contentDescription = null, + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.ptt_recording_assign_waypoint)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { onDelete(recording) }) { + Icon(Icons.Filled.Delete, contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.ptt_recording_delete), + color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ptt_recording_ok)) + } + } + } + ) +} + +/** + * Aufnahme-Bibliothek (Liste aller PTT-Aufnahmen). Aktionen pro Eintrag: + * Anhören (Play/Pause), Zuweisen, Löschen. + * + * Nur eine Aufnahme kann gleichzeitig vorgehört werden. Beim Schließen des + * Dialogs sowie beim Wechsel der Aufnahme wird der lokale Preview-Player + * sicher gestoppt und freigegeben. + */ +@Composable +fun PttRecordingsLibraryDialog( + pttManager: LivePttManager, + viewModel: WaypointViewModel, + onAssign: (PttRecording) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val recordings by pttManager.recordings.collectAsState(initial = emptyList()) + var pendingDelete by remember { mutableStateOf(null) } + + // Lokaler Preview-Player. Wird beim Schließen des Dialogs zwingend freigegeben. + val previewPlayer = remember { RecordingPreviewPlayer() } + var playingId by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + onDispose { previewPlayer.release() } + } + + Dialog(onDismissRequest = { + previewPlayer.release() + playingId = null + onDismiss() + }) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Mic, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.ptt_recordings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { + previewPlayer.release() + playingId = null + onDismiss() + }) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close)) + } + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + if (recordings.isEmpty()) { + Text( + text = stringResource(R.string.ptt_recordings_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp) + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.height(360.dp) + ) { + items(recordings, key = { it.id }) { rec -> + val isThisPlaying = playingId == rec.id + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = rec.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource( + R.string.ptt_recording_meta, + formatTimestamp(rec.createdAt), + formatDuration(rec.durationMs) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (rec.latitude != null && rec.longitude != null) { + Text( + text = "GPS: %.5f, %.5f".format(rec.latitude, rec.longitude), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(4.dp)) + // Erste Aktionsreihe: Anhören/Pause und Zuweisen. + // „Als Waypoint-Audio zuweisen" ist ein langes Label und + // konnte zuvor zusammen mit „Löschen" in einer einzelnen Reihe + // das Löschen-Element aus dem sichtbaren Bereich drängen – + // deshalb sind die Aktionen jetzt in zwei Reihen aufgeteilt, + // sodass „Löschen" immer sichtbar bleibt. + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = { + // Wechsel: bisherige Vorschau stoppen, dann neue starten + if (playingId != null && playingId != rec.id) { + previewPlayer.release() + } + previewPlayer.toggle(context, rec) { + // Natürliches Ende + playingId = null + } + playingId = if (previewPlayer.playingId == rec.id) rec.id else null + }) { + Icon( + if (isThisPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + stringResource( + if (isThisPlaying) R.string.ptt_recording_preview_pause + else R.string.ptt_recording_preview_play + ), + style = MaterialTheme.typography.labelMedium + ) + } + TextButton(onClick = { + // Vor Zuweisung Vorschau stoppen + previewPlayer.release() + playingId = null + onAssign(rec) + }) { + Icon(Icons.Filled.Assignment, contentDescription = null, + modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.ptt_recording_assign_waypoint), + style = MaterialTheme.typography.labelMedium) + } + } + // Zweite Aktionsreihe: Löschen klar sichtbar in eigener Reihe. + // OutlinedButton statt TextButton, damit der Button auch auf + // schmalen Bildschirmen visuell deutlich als Aktion erkennbar + // ist und nicht von darüberliegendem Text überdeckt wird. + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + // Wenn diese Aufnahme gerade gespielt wird, stoppen + if (playingId == rec.id) { + previewPlayer.release() + playingId = null + } + pendingDelete = rec + }, + modifier = Modifier.semantics { + contentDescription = + "Aufnahme ${rec.displayName} löschen" + } + ) { + Icon(Icons.Filled.Delete, contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.ptt_recording_delete), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium) + } + } + } + } + } + } + } + } + } + } + + pendingDelete?.let { rec -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + icon = { Icon(Icons.Filled.Delete, contentDescription = null, + tint = MaterialTheme.colorScheme.error) }, + title = { Text(stringResource(R.string.ptt_recording_delete_confirm_title)) }, + text = { Text(stringResource(R.string.ptt_recording_delete_confirm_msg, rec.displayName)) }, + confirmButton = { + Button(onClick = { + pttManager.deleteRecording(rec) + pendingDelete = null + }) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +/** + * Wegpunkt-Auswahl: Auswahl einer Tour (Dropdown) und Zuweisung der Aufnahme zu einem + * bestehenden Wegpunkt der Tour. Falls die Tour keine Wegpunkte hat, kann ein neuer + * Wegpunkt direkt aus der Aufnahme erstellt werden („Neuen Wegpunkt aus Aufnahme erstellen"). + * + * Position für den neuen Wegpunkt (Priorität): + * 1. Aktuelle GPS-Position (viewModel.currentLocation, falls vorhanden) + * 2. GPS-Position aus den Aufnahme-Metadaten + * 3. Safe Default 0.0, 0.0 (entspricht Waypoint-Datenmodell-Default) + */ +@Composable +fun WaypointChooserDialog( + recording: PttRecording, + viewModel: WaypointViewModel, + onAssigned: (Waypoint) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val tourList by viewModel.tourList.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val allWaypoints by viewModel.waypoints.collectAsState() + val currentLocation by viewModel.currentLocation.collectAsState() + + // Lokale Tour-Auswahl im Dialog – initial die aktuell aktive Tour. + var dialogTour by remember(selectedTour) { mutableStateOf(selectedTour) } + var tourMenuExpanded by remember { mutableStateOf(false) } + + val candidates = remember(dialogTour, allWaypoints) { + allWaypoints.filter { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == dialogTour } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Assignment, contentDescription = null, + tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.ptt_waypoint_chooser_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close)) + } + } + Spacer(Modifier.height(4.dp)) + + // Tour-Auswahl (Dropdown). Erlaubt Wechsel zu einer Tour mit Wegpunkten. + Box { + OutlinedButton( + onClick = { tourMenuExpanded = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.ptt_waypoint_chooser_tour_label, dialogTour), + modifier = Modifier.weight(1f) + ) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = tourMenuExpanded, + onDismissRequest = { tourMenuExpanded = false } + ) { + tourList.forEach { name -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + dialogTour = name + tourMenuExpanded = false + } + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (candidates.isEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.ptt_waypoint_chooser_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.ptt_waypoint_chooser_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = { + val lat = currentLocation?.first + ?: recording.latitude + ?: 0.0 + val lon = currentLocation?.second + ?: recording.longitude + ?: 0.0 + val baseName = recording.displayName.ifBlank { "PTT-Aufnahme" } + val newWaypoint = Waypoint( + name = baseName, + latitude = lat, + longitude = lon, + radiusMeters = 50f, + soundUri = recording.uri, + soundName = recording.displayName, + isActive = true, + tourName = dialogTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + ) + viewModel.upsert(newWaypoint) + // Wenn der Dialog eine andere Tour als die aktuell ausgewählte + // betrifft, dorthin wechseln, damit der neue Wegpunkt sichtbar wird. + if (newWaypoint.tourName != selectedTour) { + viewModel.selectTour(newWaypoint.tourName) + } + viewModel.postInfo( + context.getString( + R.string.ptt_waypoint_chooser_created, + newWaypoint.name, + newWaypoint.tourName + ) + ) + onAssigned(newWaypoint) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.AddLocationAlt, contentDescription = null, + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.ptt_waypoint_chooser_create_new)) + } + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(320.dp) + ) { + items(candidates, key = { it.id }) { wp -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + onClick = { + val updated = wp.copy( + soundUri = recording.uri, + soundName = recording.displayName + ) + viewModel.upsert(updated) + viewModel.postInfo( + context.getString( + R.string.ptt_waypoint_chooser_assigned, + updated.name.ifBlank { "(ohne Namen)" } + ) + ) + onAssigned(updated) + } + ) { + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = wp.name.ifBlank { "(ohne Namen)" }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "%.5f, %.5f · %.0f m".format( + wp.latitude, wp.longitude, wp.radiusMeters + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (wp.soundName.isNotBlank()) { + Text( + text = "Audio: ${wp.soundName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + } + } + } + Spacer(Modifier.height(8.dp)) + // Zusätzlich: auch in nicht-leeren Touren kann ein neuer Wegpunkt + // direkt aus der Aufnahme erstellt werden. + OutlinedButton( + onClick = { + val lat = currentLocation?.first + ?: recording.latitude + ?: 0.0 + val lon = currentLocation?.second + ?: recording.longitude + ?: 0.0 + val baseName = recording.displayName.ifBlank { "PTT-Aufnahme" } + val newWaypoint = Waypoint( + name = baseName, + latitude = lat, + longitude = lon, + radiusMeters = 50f, + soundUri = recording.uri, + soundName = recording.displayName, + isActive = true, + tourName = dialogTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + ) + viewModel.upsert(newWaypoint) + if (newWaypoint.tourName != selectedTour) { + viewModel.selectTour(newWaypoint.tourName) + } + viewModel.postInfo( + context.getString( + R.string.ptt_waypoint_chooser_created, + newWaypoint.name, + newWaypoint.tourName + ) + ) + onAssigned(newWaypoint) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.AddLocationAlt, contentDescription = null, + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.ptt_waypoint_chooser_create_new)) + } + } + + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerModel.kt b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerModel.kt new file mode 100644 index 0000000..a749240 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerModel.kt @@ -0,0 +1,121 @@ +package de.waypointaudio.ui + +import java.util.UUID + +/** + * v2.4.2 — Explorer-Routenplaner: zustandsneutrale Datenmodelle. + * + * Bewusst klein und Composable-freundlich: Eine Route besteht aus + * (Start, optionalen Via-Stopps, Ziel). Jeder Stopp kennt seine Quelle + * (Aktueller Standort, Kartenmitte, Wegpunkt, POI, manuell getippt), + * damit die UI sinnvolle Labels anzeigen kann. + * + * - Keine Persistenz: Der Planer-Zustand lebt nur im Sheet-Scope. + * - Keine Auto-Berechnung: Routing wird nur auf Nutzer-Klick ausgelöst. + * - Kein verbindlicher Token: Wenn kein Endpunkt konfiguriert ist, + * bleibt nur die Planlinie (Luftlinie) sichtbar. + */ + +/** Quelle, aus der ein Routen-Stopp gewählt wurde. */ +enum class RouteStopSource { + /** Aktueller GPS-Standort des Geräts. */ + CURRENT_LOCATION, + + /** Kartenmitte zum Zeitpunkt der Auswahl. */ + MAP_CENTER, + + /** Vorhandener Wegpunkt der aktuellen Tour. */ + WAYPOINT, + + /** Treffer aus der Online-POI-Suche (Overpass). */ + POI, + + /** Vom Nutzer manuell eingetippte Koordinate (Lat/Lon-Felder). */ + MANUAL, + + /** Treffer aus der Ortssuche (Nominatim). */ + SEARCH, +} + +/** + * Ein einzelner Routen-Stopp (Start, Via oder Ziel). + * + * - [id] stabile UI-Identität für Listen-Operationen. + * - [label] menschenlesbare Beschriftung (z. B. POI-Name, Wegpunktname, + * "Mein Standort", "Eigene Koordinate"). + */ +data class RouteStop( + val id: String = UUID.randomUUID().toString(), + val latitude: Double, + val longitude: Double, + val label: String, + val source: RouteStopSource, +) + +/** + * v2.5.2 — Routingprofil für die Berechnung. + * + * Mapping auf den OSRM-kompatiblen Pfad-Bestandteil: + * - [DRIVING] → "driving" (Auto) + * - [CYCLING] → "cycling" (Fahrrad) + * - [WALKING] → "walking" (Fußgänger) + * + * Die Schiff-/Boot-Variante ist bewusst ausgespart (Nutzerwunsch v2.5.2). + * Persistente Tourrouten speichern den OSRM-Schlüssel (`driving`/`cycling`/`walking`) + * als String — bestehende Routen ohne explizites Profil fallen sicher auf `driving`. + */ +enum class RoutingProfile(val osrmKey: String) { + DRIVING("driving"), + CYCLING("cycling"), + WALKING("walking"); + + companion object { + /** Tolerantes Mapping aus persistierten oder importierten Strings. */ + fun fromKey(key: String?): RoutingProfile { + if (key.isNullOrBlank()) return DRIVING + return when (key.trim().lowercase()) { + "cycling", "bike", "bicycle" -> CYCLING + "walking", "foot", "pedestrian" -> WALKING + else -> DRIVING + } + } + } +} + +/** + * Komplette Routen-Definition: Start + Via-Liste + Ziel. + * + * Alle drei Felder können `null` sein, solange der Plan noch unvollständig ist. + * Eine Berechnung ist erst möglich, wenn Start und Ziel gesetzt sind. + * + * v2.5.2: [profile] hält die aktuelle Fortbewegungsart (Auto/Fahrrad/Fußgänger); + * Default Auto, damit Bestandsverhalten unverändert bleibt. + */ +data class RoutePlan( + val start: RouteStop? = null, + val via: List = emptyList(), + val destination: RouteStop? = null, + val profile: RoutingProfile = RoutingProfile.DRIVING, +) { + val isComplete: Boolean get() = start != null && destination != null + + /** Liefert alle Stops in Reihenfolge Start → Via … → Ziel. */ + fun orderedStops(): List { + val out = mutableListOf() + start?.let { out += it } + out += via + destination?.let { out += it } + return out + } + + /** Lat/Lon-Paare für OSRM (Start..Via..Ziel). */ + fun toOsrmPoints(): List> = + orderedStops().map { it.latitude to it.longitude } +} + +/** + * Welche Rolle ein Stopp in der Route spielt. Wird vom UI an die + * Auswahl-Quellen-Callbacks weitergereicht, um zu wissen, wohin der + * gewählte Punkt landen soll. + */ +enum class RouteStopRole { START, VIA, DESTINATION } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt new file mode 100644 index 0000000..85e9c19 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/RoutePlannerSheet.kt @@ -0,0 +1,1400 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +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.AddLocationAlt +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.CenterFocusStrong +import androidx.compose.material.icons.filled.DirectionsBike +import androidx.compose.material.icons.filled.DirectionsCar +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.Flag +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.Save +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.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +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.OutlinedTextField +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.Waypoint +import de.waypointaudio.util.NominatimClient +import de.waypointaudio.util.OverpassPoi +import de.waypointaudio.util.OverpassPoiClient +import kotlinx.coroutines.launch + +/** + * v2.4.2 — Explorer-Routenplaner als ModalBottomSheet. + * + * 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, + * 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). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoutePlannerSheet( + plan: RoutePlan, + routeStatus: String?, + hasRoute: Boolean, + hasPlanline: Boolean, + isLoading: Boolean, + routingEndpoint: String, + showRouteOverlay: Boolean, + onShowRouteOverlayChange: (Boolean) -> Unit, + onPlanChange: (RoutePlan) -> Unit, + onComputeRoute: () -> Unit, + onShowPlanline: () -> Unit, + onClear: () -> Unit, + onConfigureEndpoint: () -> Unit, + onUseDemoEndpoint: () -> Unit, + onSaveAsTourDraft: (String) -> Unit, + onAddStopsAsWaypoints: () -> Unit, + // v2.4.6 — Tourrouten-Aktionen + Status. Wenn keine aktive Tour vorhanden ist, + // bleibt `currentTourName` null und die Aktionen sind deaktiviert. + currentTourName: String? = null, + savedTourRoute: de.waypointaudio.data.TourRoute? = null, + onSaveTourRoute: () -> Unit = {}, + onReplaceTourRoute: () -> Unit = {}, + onDeleteTourRoute: () -> Unit = {}, + // Quellenanbieter: + currentLocationLatLon: Pair?, + mapCenterLatLon: Pair?, + availableWaypoints: List, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + + var pickerForRole by remember { mutableStateOf(null) } + var showWaypointPicker by remember { mutableStateOf(null) } + var showPoiPicker by remember { mutableStateOf(null) } + var showSearchPicker by remember { mutableStateOf(null) } + var showManualPicker by remember { mutableStateOf(null) } + var showSaveTourDialog by remember { mutableStateOf(false) } + + fun applyStopToRole(role: RouteStopRole, stop: RouteStop) { + val next = when (role) { + RouteStopRole.START -> plan.copy(start = stop) + RouteStopRole.DESTINATION -> plan.copy(destination = stop) + RouteStopRole.VIA -> plan.copy(via = plan.via + stop) + } + onPlanChange(next) + } + + // v2.4.3 — Layout-Hotfix: + // - Window-Insets der Systemnavigation werden berücksichtigt (sonst + // 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. + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Fixer Header (oberhalb des Scroll-Bereichs) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.route_planner_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource(R.string.route_planner_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Filled.Close, contentDescription = null) + } + } + + // Scrollbarer Hauptbereich + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) + .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) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (routingEndpoint.isBlank()) Icons.Filled.Settings else Icons.Filled.AltRoute, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + 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, + 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) + // + // 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)) + } + } + ) + } + } + } + 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( + routeStatus, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } else if (!hasRoute && !hasPlanline) { + Text( + stringResource(R.string.route_planner_no_route), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // 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, + savedRoute = savedTourRoute, + hasComputedRoute = hasRoute, + hasPlanline = hasPlanline, + onSaveTourRoute = onSaveTourRoute, + onReplaceTourRoute = onReplaceTourRoute, + onDeleteTourRoute = onDeleteTourRoute + ) + } + + // Storyboard/Tour-Integration — bleibt im Scroll-Bereich, + // weil sie nur bei vollständigem Plan erscheint und kein + // Primärziel jedes Aufrufs ist. + if (plan.isComplete) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { showSaveTourDialog = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.MovieFilter, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_save_as_tour)) + } + Button( + onClick = onAddStopsAsWaypoints, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Place, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_add_stops_as_waypoints)) + } + } + } + + // Footer-Aktionen (Löschen / Schließen) — bleiben im + // Scroll-Bereich, damit die Sticky-Bar unten nur die + // Hauptaktionen 'Route berechnen' / 'Planlinie' enthält. + Row(modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onClear, modifier = Modifier.weight(1f)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_clear)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + } + } // 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. + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .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( + onClick = onShowPlanline, + enabled = plan.isComplete && !isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Timeline, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_planline)) + } + } + } + } + } + } + + // Quelle-Auswahl-Dialog + pickerForRole?.let { role -> + SourcePickerDialog( + role = role, + currentLocation = currentLocationLatLon, + mapCenter = mapCenterLatLon, + waypointCount = availableWaypoints.size, + onUseCurrent = { + val ll = currentLocationLatLon + if (ll != null) { + applyStopToRole( + role, + RouteStop( + latitude = ll.first, longitude = ll.second, + label = ctx.getString(R.string.route_planner_label_current_location), + source = RouteStopSource.CURRENT_LOCATION + ) + ) + } + pickerForRole = null + }, + onUseMapCenter = { + val ll = mapCenterLatLon + if (ll != null) { + applyStopToRole( + role, + RouteStop( + latitude = ll.first, longitude = ll.second, + label = ctx.getString(R.string.route_planner_label_map_center), + source = RouteStopSource.MAP_CENTER + ) + ) + } + pickerForRole = null + }, + onPickWaypoint = { + showWaypointPicker = role + pickerForRole = null + }, + onPickPoi = { + showPoiPicker = role + pickerForRole = null + }, + onPickSearch = { + showSearchPicker = role + pickerForRole = null + }, + onPickManual = { + showManualPicker = role + pickerForRole = null + }, + onDismiss = { pickerForRole = null } + ) + } + + showWaypointPicker?.let { role -> + WaypointPickerDialog( + waypoints = availableWaypoints, + onSelected = { wp -> + applyStopToRole( + role, + RouteStop( + latitude = wp.latitude, longitude = wp.longitude, + label = wp.name.ifBlank { ctx.getString(R.string.map_waypoint_unnamed) }, + source = RouteStopSource.WAYPOINT + ) + ) + showWaypointPicker = null + }, + onDismiss = { showWaypointPicker = null } + ) + } + + showManualPicker?.let { role -> + ManualCoordinateDialog( + initialLat = (currentLocationLatLon ?: mapCenterLatLon)?.first, + initialLon = (currentLocationLatLon ?: mapCenterLatLon)?.second, + onConfirm = { lat, lon -> + applyStopToRole( + role, + RouteStop( + latitude = lat, longitude = lon, + label = ctx.getString(R.string.route_planner_label_manual), + source = RouteStopSource.MANUAL + ) + ) + showManualPicker = null + }, + onDismiss = { showManualPicker = null } + ) + } + + showPoiPicker?.let { role -> + PoiPickerDialog( + centerLatLon = currentLocationLatLon ?: mapCenterLatLon, + onSelected = { poi -> + applyStopToRole( + role, + RouteStop( + latitude = poi.latitude, longitude = poi.longitude, + label = poi.displayName.take(60), + source = RouteStopSource.POI + ) + ) + showPoiPicker = null + }, + onDismiss = { showPoiPicker = null } + ) + } + + showSearchPicker?.let { role -> + PlaceSearchDialog( + onDismiss = { showSearchPicker = null }, + onResultSelected = { r -> + applyStopToRole( + role, + RouteStop( + latitude = r.latitude, longitude = r.longitude, + label = r.displayName.take(60), + source = RouteStopSource.SEARCH + ) + ) + showSearchPicker = null + } + ) + } + + if (showSaveTourDialog) { + SaveAsTourDialog( + onConfirm = { tourName -> + onSaveAsTourDraft(tourName) + showSaveTourDialog = false + }, + onDismiss = { showSaveTourDialog = false } + ) + } +} + +@Composable +private fun RouteStopRow( + stop: RouteStop?, + roleLabel: String, + roleIcon: androidx.compose.ui.graphics.vector.ImageVector, + placeholder: String, + onChoose: (() -> Unit)?, + onClear: () -> Unit, + showOrderControls: Boolean, + isFirst: Boolean = false, + isLast: Boolean = false, + onMoveUp: () -> Unit = {}, + onMoveDown: () -> Unit = {}, +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + roleIcon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + roleLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (stop != null) { + Text( + stop.label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + "%.5f, %.5f".format(java.util.Locale.US, stop.latitude, stop.longitude), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } else { + Text( + placeholder, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (showOrderControls && stop != null) { + IconButton(onClick = onMoveUp, enabled = !isFirst) { + Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.route_planner_move_via_up)) + } + IconButton(onClick = onMoveDown, enabled = !isLast) { + Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.route_planner_move_via_down)) + } + } + if (stop == null && onChoose != null) { + TextButton(onClick = onChoose) { + Text(stringResource(R.string.route_planner_picker_title)) + } + } else if (stop != null) { + IconButton(onClick = onClear) { + Icon(Icons.Filled.Close, contentDescription = null) + } + } + } + } +} + +@Composable +private fun SourcePickerDialog( + role: RouteStopRole, + currentLocation: Pair?, + mapCenter: Pair?, + waypointCount: Int, + onUseCurrent: () -> Unit, + onUseMapCenter: () -> Unit, + onPickWaypoint: () -> Unit, + onPickPoi: () -> Unit, + onPickSearch: () -> Unit, + onPickManual: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + when (role) { + RouteStopRole.START -> stringResource(R.string.route_planner_choose_start) + RouteStopRole.VIA -> stringResource(R.string.route_planner_add_via) + RouteStopRole.DESTINATION -> stringResource(R.string.route_planner_choose_destination) + } + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SourceOption( + icon = Icons.Filled.MyLocation, + label = if (currentLocation != null) stringResource(R.string.route_planner_picker_current_location) + else stringResource(R.string.route_planner_picker_current_location_unavailable), + enabled = currentLocation != null, + onClick = onUseCurrent + ) + SourceOption( + icon = Icons.Filled.CenterFocusStrong, + label = stringResource(R.string.route_planner_picker_map_center), + enabled = mapCenter != null, + onClick = onUseMapCenter + ) + SourceOption( + icon = Icons.Filled.Place, + label = if (waypointCount > 0) stringResource(R.string.route_planner_picker_waypoint) + else stringResource(R.string.route_planner_picker_waypoint_empty), + enabled = waypointCount > 0, + onClick = onPickWaypoint + ) + SourceOption( + icon = Icons.Filled.Bookmarks, + label = stringResource(R.string.route_planner_picker_poi), + enabled = true, + onClick = onPickPoi + ) + SourceOption( + icon = Icons.Filled.Search, + label = stringResource(R.string.route_planner_picker_search), + enabled = true, + onClick = onPickSearch + ) + SourceOption( + icon = Icons.Filled.Edit, + label = stringResource(R.string.route_planner_picker_manual), + enabled = true, + onClick = onPickManual + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun SourceOption( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(10.dp), + color = if (enabled) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth() + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Text(label, modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun WaypointPickerDialog( + waypoints: List, + onSelected: (Waypoint) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.route_planner_picker_waypoint)) }, + text = { + if (waypoints.isEmpty()) { + Text(stringResource(R.string.route_planner_picker_waypoint_empty)) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + ) { + items(waypoints) { wp -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = { onSelected(wp) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Place, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + "%.5f, %.5f".format(java.util.Locale.US, wp.latitude, wp.longitude), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + } + ) +} + +@Composable +private fun ManualCoordinateDialog( + initialLat: Double?, + initialLon: Double?, + onConfirm: (Double, Double) -> Unit, + onDismiss: () -> Unit, +) { + var latText by remember { mutableStateOf(initialLat?.let { "%.6f".format(java.util.Locale.US, it) }.orEmpty()) } + var lonText by remember { mutableStateOf(initialLon?.let { "%.6f".format(java.util.Locale.US, it) }.orEmpty()) } + var error by remember { mutableStateOf(null) } + val ctx = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.route_planner_manual_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = latText, + onValueChange = { latText = it; error = null }, + label = { Text(stringResource(R.string.route_planner_manual_lat)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = lonText, + onValueChange = { lonText = it; error = null }, + label = { Text(stringResource(R.string.route_planner_manual_lon)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (error != null) { + Text(error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall) + } + } + }, + confirmButton = { + TextButton(onClick = { + val lat = latText.replace(',', '.').toDoubleOrNull() + val lon = lonText.replace(',', '.').toDoubleOrNull() + if (lat == null || lon == null || lat !in -90.0..90.0 || lon !in -180.0..180.0) { + error = ctx.getString(R.string.route_planner_manual_invalid) + } else { + onConfirm(lat, lon) + } + }) { Text(stringResource(R.string.route_planner_manual_apply)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} + +@Composable +private fun PoiPickerDialog( + centerLatLon: Pair?, + onSelected: (OverpassPoi) -> Unit, + onDismiss: () -> Unit, +) { + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + var results by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.route_planner_picker_poi)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.route_planner_poi_explanation), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text(stringResource(R.string.routing_poi_search_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + val ll = centerLatLon + if (ll == null) { + error = ctx.getString(R.string.explorer_hint_no_location) + return@Button + } + loading = true + error = null + scope.launch { + runCatching { + OverpassPoiClient.search(ctx, query, ll.first, ll.second) + }.onSuccess { + results = it + if (it.isEmpty()) { + error = ctx.getString(R.string.routing_poi_search_no_results) + } + }.onFailure { + error = ctx.getString(R.string.routing_poi_search_error) + } + loading = false + } + }, + enabled = !loading && query.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + if (loading) stringResource(R.string.routing_poi_search_loading) + else stringResource(R.string.routing_poi_search_button) + ) + } + Text( + stringResource(R.string.routing_poi_search_attribution), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall) + } + if (loading) { + Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + } + } else if (results.isNotEmpty()) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp) + ) { + items(results) { poi -> + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = { onSelected(poi) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Place, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + poi.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + poi.category, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } + } + ) +} + +@Composable +private fun SaveAsTourDialog( + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.route_planner_save_as_tour_dialog_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringResource(R.string.route_planner_save_as_tour_dialog_hint)) }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(name.trim().ifBlank { "Routenentwurf" }) } + ) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.route_planner_save_as_tour)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} + +/** + * v2.4.6 — Statuskarte für die gespeicherte Tourroute. + * + * Zeigt deutlich an, ob die aktuelle Tour eine gespeicherte Route besitzt. + * Die Aktionen sind bewusst getrennt: + * - Speichern: erzeugt erstmalig eine gespeicherte Tourroute (nur sichtbar, + * wenn aktuell eine berechnete Route oder Planlinie vorliegt UND keine + * gespeicherte Route existiert). + * - Ersetzen: überschreibt eine vorhandene gespeicherte Route mit der + * aktuell sichtbaren (verhindert versehentliches Überschreiben). + * - Löschen: entfernt die gespeicherte Tourroute. + */ +@Composable +private fun TourRouteStatusCard( + tourName: String, + savedRoute: de.waypointaudio.data.TourRoute?, + hasComputedRoute: Boolean, + hasPlanline: Boolean, + onSaveTourRoute: () -> Unit, + onReplaceTourRoute: () -> Unit, + onDeleteTourRoute: () -> Unit, +) { + val hasUsableLive = hasComputedRoute || hasPlanline + Surface( + shape = RoundedCornerShape(12.dp), + color = if (savedRoute != null) + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.tour_route_status_title, tourName), + style = MaterialTheme.typography.labelMedium + ) + if (savedRoute != null) { + val distStr = savedRoute.distanceMeters?.let { + de.waypointaudio.util.OsrmRoutingClient.formatDistance(it) + } + val durStr = savedRoute.durationSeconds?.let { + de.waypointaudio.util.OsrmRoutingClient.formatDuration(it) + } + val updatedStr = run { + val d = java.util.Date(savedRoute.updatedAtMillis) + java.text.SimpleDateFormat( + "dd.MM.yyyy HH:mm", java.util.Locale.GERMANY + ).format(d) + } + val detail = buildString { + if (distStr != null) append(distStr) + if (durStr != null) { + if (isNotEmpty()) append(" · ") + append(durStr) + } + if (isNotEmpty()) append(" · ") + append(stringResource(R.string.tour_route_updated_at, updatedStr)) + } + Text( + detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (savedRoute.endpoint.isNotBlank()) { + Text( + stringResource(R.string.tour_route_source, savedRoute.endpoint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Text( + stringResource(R.string.tour_route_status_none), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + if (savedRoute == null) { + Button( + onClick = onSaveTourRoute, + enabled = hasUsableLive, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.tour_route_save)) + } + } else { + Button( + onClick = onReplaceTourRoute, + enabled = hasUsableLive, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.tour_route_replace)) + } + TextButton( + onClick = onDeleteTourRoute, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.tour_route_delete)) + } + } + } + if (!hasUsableLive && savedRoute == null) { + Text( + stringResource(R.string.tour_route_hint_compute_first), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * v2.5.2 — Segment-/Chip-Auswahl für das Routingprofil. + * + * Drei kompakte Pillen (Auto/Fahrrad/Fußgänger) im v2.5.1-Designstil: + * - Aktive Auswahl mit Primary-Background, kontrastreichem Icon+Label. + * - Inaktive Auswahl mit SurfaceVariant-Background. + * - Schiff/Boot ist bewusst nicht enthalten (Nutzerwunsch v2.5.2). + * + * Bewusst keine `SegmentedButtonRow` — die Material3-API hat hier (in der + * im Projekt verwendeten Compose-Version) gelegentlich Layout-Probleme bei + * langen Labels mit Icon. Eine Row aus Surface-Pillen liefert ein + * vorhersehbares, abgeschnittenes-frei darstellbares Layout, das auch auf + * schmalen Geräten gut aussieht. + */ +@Composable +private fun RoutingProfileSelector( + selected: RoutingProfile, + onSelectedChange: (RoutingProfile) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + ProfilePill( + label = stringResource(R.string.route_planner_profile_driving), + icon = Icons.Filled.DirectionsCar, + selected = selected == RoutingProfile.DRIVING, + onClick = { onSelectedChange(RoutingProfile.DRIVING) }, + modifier = Modifier.weight(1f) + ) + ProfilePill( + label = stringResource(R.string.route_planner_profile_cycling), + icon = Icons.Filled.DirectionsBike, + selected = selected == RoutingProfile.CYCLING, + onClick = { onSelectedChange(RoutingProfile.CYCLING) }, + modifier = Modifier.weight(1f) + ) + ProfilePill( + label = stringResource(R.string.route_planner_profile_walking), + icon = Icons.Filled.DirectionsWalk, + selected = selected == RoutingProfile.WALKING, + onClick = { onSelectedChange(RoutingProfile.WALKING) }, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ProfilePill( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + val content = if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant + Surface( + shape = RoundedCornerShape(20.dp), + color = container, + tonalElevation = if (selected) 2.dp else 0.dp, + modifier = modifier + ) { + TextButton( + onClick = onClick, + colors = androidx.compose.material3.ButtonDefaults.textButtonColors( + contentColor = content + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun LineLegendChip(color: Int, label: String, solid: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(2.dp), + color = androidx.compose.ui.graphics.Color(color).copy(alpha = if (solid) 1f else 0.55f), + modifier = Modifier + .width(16.dp) + .height(if (solid) 4.dp else 3.dp) + ) {} + Spacer(Modifier.width(6.dp)) + Text(label, style = MaterialTheme.typography.labelSmall) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/RoutingPoiSheet.kt b/app/src/main/kotlin/de/waypointaudio/ui/RoutingPoiSheet.kt new file mode 100644 index 0000000..6901c06 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/RoutingPoiSheet.kt @@ -0,0 +1,377 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +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.OutlinedTextField +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.util.OverpassPoi +import de.waypointaudio.util.OverpassPoiClient +import kotlinx.coroutines.launch + +/** + * v2.3.1 — Routing & Online-POI MVP. + * + * Bewusst sparsam, kostenneutral und ehrlich abgegrenzt: + * - Routing: konfigurierbarer OSRM-kompatibler Endpunkt (self-hosted bevorzugt). + * Ohne konfigurierten Endpunkt wird kein Netzwerk-Call ausgelöst — Storyboard- + * /Luftlinie bleibt Fallback. + * - POI: nutzergetriggerte Overpass-Suche (kein Polling, kein Auto-Refresh). + * Treffer können als Wegpunkt der aktuellen Tour übernommen oder ins + * Storyboard (separater Sheet) gemerkt werden. + * + * Diese Komponente ist absichtlich state-leicht: Sie liefert Aktionen via + * Callbacks zurück (POI in Tour übernehmen, Route berechnen). Die Daten- + * persistenz und Karten-Darstellung der Route übernimmt der Aufrufer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoutingPoiSheet( + centerLat: Double?, + centerLon: Double?, + routingEndpoint: String, + onRoutingEndpointChange: (String) -> Unit, + onAddPoiAsWaypoint: (OverpassPoi) -> Unit, + onAddPoiToStoryboard: (OverpassPoi) -> Unit, + onComputeRoute: () -> Unit, + routeStatus: String?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + var endpointDraft by remember(routingEndpoint) { mutableStateOf(routingEndpoint) } + var query by remember { mutableStateOf("") } + var results by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(false) } + var errorMsg by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.routing_poi_title), + style = MaterialTheme.typography.titleMedium + ) + } + + // --- Routing endpoint config --- + Surface( + shape = RoundedCornerShape(12.dp), + color = 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) + ) { + Text( + stringResource(R.string.routing_endpoint_label), + style = MaterialTheme.typography.labelMedium + ) + Text( + stringResource(R.string.routing_endpoint_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + // v2.4.4: Beide Eingabeformen sind erlaubt — Hinweis dazu. + Text( + stringResource(R.string.routing_endpoint_accepted_formats), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f) + ) + OutlinedTextField( + value = endpointDraft, + onValueChange = { endpointDraft = it }, + singleLine = true, + placeholder = { Text("https://router.project-osrm.org") }, + modifier = Modifier.fillMaxWidth() + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { onRoutingEndpointChange(endpointDraft) }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.routing_endpoint_save)) + } + Button( + onClick = onComputeRoute, + enabled = routingEndpoint.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.routing_compute_button)) + } + } + // v2.4.4: One-Click Demo-Setup. Trägt den Demo-Endpunkt + // in den Entwurf ein, der Nutzer kann dann Speichern. + TextButton( + onClick = { + endpointDraft = de.waypointaudio.util.OsrmRoutingClient.DEMO_ENDPOINT + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.routing_endpoint_use_demo)) + } + if (routingEndpoint.isBlank()) { + Text( + stringResource(R.string.routing_endpoint_not_configured), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f) + ) + } else if (routingEndpoint.contains("router.project-osrm.org")) { + Text( + stringResource(R.string.routing_endpoint_demo_warning), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + if (routeStatus != null) { + Text( + routeStatus, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + // --- POI search --- + Surface( + shape = RoundedCornerShape(12.dp), + color = 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) + ) { + Text( + stringResource(R.string.routing_poi_search_label), + style = MaterialTheme.typography.labelMedium + ) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + placeholder = { Text(stringResource(R.string.routing_poi_search_hint)) }, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + val lat = centerLat + val lon = centerLon + if (lat == null || lon == null) { + errorMsg = context.getString(R.string.explorer_hint_no_location) + return@Button + } + loading = true + errorMsg = null + scope.launch { + runCatching { + OverpassPoiClient.search(context, query, lat, lon) + }.onSuccess { + results = it + if (it.isEmpty()) { + errorMsg = context.getString(R.string.routing_poi_search_no_results) + } + }.onFailure { + errorMsg = context.getString(R.string.routing_poi_search_error) + } + loading = false + } + }, + enabled = !loading && query.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + if (loading) stringResource(R.string.routing_poi_search_loading) + else stringResource(R.string.routing_poi_search_button) + ) + } + Text( + stringResource(R.string.routing_poi_search_attribution), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + ) + errorMsg?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error) + } + } + } + + // --- Results --- + if (loading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (results.isNotEmpty()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(results) { poi -> + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Place, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(poi.displayName, style = MaterialTheme.typography.bodyMedium) + val distM = if (centerLat != null && centerLon != null) { + haversineMeters(centerLat, centerLon, poi.latitude, poi.longitude) + } else null + Text( + buildString { + append(poi.category) + if (distM != null) { + append(" · ") + append( + context.getString( + R.string.routing_poi_distance, + if (distM >= 1000) "%.1f km".format(distM / 1000.0) + else "%d m".format(distM.toInt()) + ) + ) + } + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TextButton(onClick = { onAddPoiAsWaypoint(poi) }) { + Icon( + Icons.Filled.Place, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.routing_poi_add_waypoint)) + } + TextButton(onClick = { onAddPoiToStoryboard(poi) }) { + Icon( + Icons.Filled.MovieFilter, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.routing_poi_add_storyboard)) + } + } + } + } + } + } + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.close)) + } + } + } +} + +private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dPhi = Math.toRadians(lat2 - lat1) + val dLam = Math.toRadians(lon2 - lon1) + val a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) + + Math.cos(phi1) * Math.cos(phi2) * + Math.sin(dLam / 2) * Math.sin(dLam / 2) + val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + return r * c +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/TermsDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/TermsDialog.kt new file mode 100644 index 0000000..8efc4d3 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/TermsDialog.kt @@ -0,0 +1,102 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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 + +/** + * v2.5.9 — Erstmaliger Nutzungsbedingungen-/Lizenzhinweis-Dialog. + * + * Versioniert über [de.waypointaudio.data.TermsVersions.CURRENT]; + * Zustimmung wird im [de.waypointaudio.data.TermsStore] persistiert. + * Ablehnen beendet die Activity — die App lässt sich nicht ohne + * Zustimmung verwenden. + */ +@Composable +fun TermsDialog( + onAccept: () -> Unit, + onDecline: () -> Unit +) { + AlertDialog( + onDismissRequest = { /* keine versehentliche Dismiss-Aktion */ }, + title = { + Text( + text = stringResource(R.string.terms_dialog_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(R.string.terms_dialog_intro), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(R.string.terms_dialog_license_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.terms_dialog_license_body), + style = MaterialTheme.typography.bodySmall + ) + + Text( + text = stringResource(R.string.terms_dialog_commercial_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.terms_dialog_commercial_body), + style = MaterialTheme.typography.bodySmall + ) + + Text( + text = stringResource(R.string.terms_dialog_liability_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.terms_dialog_liability_body), + style = MaterialTheme.typography.bodySmall + ) + + Text( + text = stringResource(R.string.terms_dialog_version_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + }, + confirmButton = { + TextButton(onClick = onAccept) { + Text(stringResource(R.string.terms_dialog_accept)) + } + }, + dismissButton = { + TextButton(onClick = onDecline) { + Text(stringResource(R.string.terms_dialog_decline)) + } + } + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftEditorScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftEditorScreen.kt new file mode 100644 index 0000000..1373e45 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftEditorScreen.kt @@ -0,0 +1,930 @@ +package de.waypointaudio.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.heightIn +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import de.waypointaudio.R +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.TrackDraft +import de.waypointaudio.util.NominatimClient +import de.waypointaudio.util.NominatimResult +import de.waypointaudio.viewmodel.WaypointViewModel +import kotlinx.coroutines.launch +import org.osmdroid.config.Configuration +import org.osmdroid.events.MapEventsReceiver +import de.waypointaudio.data.MapProvider +import de.waypointaudio.data.MapProviderStore +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.MapEventsOverlay +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline +import java.io.File +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Karten-basierter Editor für einen [TrackDraft]. + * + * Interaktion: + * - Tippen auf einen Streckenpunkt → schaltet Markierung für späteren Import + * als Wegpunkt um. + * - Lang drücken auf einen Streckenpunkt → wählt ihn zum Verschieben/Löschen. + * - Lang drücken auf die Karte → verschiebt den ausgewählten Punkt dorthin. + * - Toolbar: Alle markieren / Markierungen löschen, Umbenennen, Track löschen. + * - FAB "In Tour übernehmen": importiert nur markierte Punkte. Ohne Markierung + * deaktiviert mit erklärendem Hinweis. + * + * Während aktiver GPS-Aufzeichnung sind Bearbeiten/Importieren gesperrt. + * + * Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL). + */ +@SuppressLint("MissingPermission") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackDraftEditorScreen( + viewModel: WaypointViewModel, + draftId: String, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val mapProviderStore = remember { MapProviderStore(context.applicationContext) } + val selectedMapProvider by mapProviderStore.provider.collectAsState(initial = MapProvider.DEFAULT) + val drafts by viewModel.trackDrafts.collectAsState() + val tourList by viewModel.tourList.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val isRecording by viewModel.isRecording.collectAsState() + + val storedDraft = drafts.firstOrNull { it.id == draftId } + var working by remember(draftId, storedDraft?.updatedAt) { + mutableStateOf(storedDraft ?: TrackDraft(id = draftId)) + } + + LaunchedEffect(storedDraft) { + if (storedDraft == null) onNavigateBack() + } + + var selectedIndex by remember { mutableStateOf(null) } + var showRenameDialog by remember { mutableStateOf(false) } + var showImportDialog by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showSearchDialog by remember { mutableStateOf(false) } + var searchPin by remember { mutableStateOf(null) } + var dirty by remember { mutableStateOf(false) } + + val primary = MaterialTheme.colorScheme.primary + val tertiary = MaterialTheme.colorScheme.tertiary + val errorColor = MaterialTheme.colorScheme.error + val markedColor = Color(0xFFFFAB00) // distinctive amber for marked points + + LaunchedEffect(Unit) { + Configuration.getInstance().apply { + userAgentValue = context.packageName + osmdroidTileCache = File(context.cacheDir, "osmdroid") + } + } + + var mapView by remember { mutableStateOf(null) } + + fun toggleMark(idx: Int) { + if (idx !in working.points.indices) return + val isMarked = idx in working.markedIndexes + val newMarked = if (isMarked) + working.markedIndexes - idx + else + (working.markedIndexes + idx).distinct().sorted() + working = working.copy(markedIndexes = newMarked) + dirty = true + viewModel.saveDraft(working) + } + + fun deleteSelected() { + val sel = selectedIndex ?: return + if (isRecording) return + val pts = working.points.toMutableList() + if (sel !in pts.indices) return + pts.removeAt(sel) + val newMarked = working.markedIndexes + .filter { it != sel } + .map { if (it > sel) it - 1 else it } + working = working.copy(points = pts, markedIndexes = newMarked) + dirty = true + selectedIndex = null + viewModel.saveDraft(working) + } + + LaunchedEffect(selectedMapProvider) { + val mv = mapView ?: return@LaunchedEffect + mv.setTileSource(selectedMapProvider.toTileSource()) + mv.invalidate() + } + + LaunchedEffect(working, selectedIndex, isRecording, searchPin) { + val mv = mapView ?: return@LaunchedEffect + drawDraftOverlays( + mv = mv, + draft = working, + selectedIndex = selectedIndex, + polyArgb = tertiary.toArgb(), + pointArgb = primary.toArgb(), + markedArgb = markedColor.toArgb(), + selectedArgb = errorColor.toArgb(), + searchPin = searchPin, + onPointTapped = { idx -> toggleMark(idx) } + ) + } + + DisposableEffect(Unit) { + onDispose { + if (dirty) viewModel.saveDraft(working) + mapView?.onDetach() + } + } + + if (showRenameDialog) { + var nameField by remember { mutableStateOf(working.name) } + var notesField by remember { mutableStateOf(working.notes) } + AlertDialog( + onDismissRequest = { showRenameDialog = false }, + title = { Text(stringResource(R.string.draft_edit_meta_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = nameField, + onValueChange = { nameField = it }, + label = { Text(stringResource(R.string.draft_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = notesField, + onValueChange = { notesField = it }, + label = { Text(stringResource(R.string.draft_notes_label)) }, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button(onClick = { + working = working.copy(name = nameField.trim(), notes = notesField) + dirty = true + viewModel.saveDraft(working) + showRenameDialog = false + }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = { showRenameDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showImportDialog) { + DraftImportDialog( + draft = working, + tourList = tourList, + preselectedTour = selectedTour, + isRecording = isRecording, + onConfirm = { tour -> + viewModel.importDraftAsWaypoints(working, tour) + showImportDialog = false + onNavigateBack() + }, + onDismiss = { showImportDialog = false } + ) + } + + if (showSearchDialog) { + PlaceSearchDialog( + onDismiss = { showSearchDialog = false }, + onResultSelected = { result -> + val gp = GeoPoint(result.latitude, result.longitude) + searchPin = gp + mapView?.controller?.animateTo(gp) + mapView?.controller?.setZoom(15.0) + showSearchDialog = false + } + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(R.string.draft_delete_confirm_title)) }, + text = { Text(stringResource(R.string.draft_delete_confirm_msg, working.name.ifBlank { "Draft" })) }, + confirmButton = { + Button( + onClick = { + viewModel.deleteDraft(working.id) + showDeleteConfirm = false + onNavigateBack() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { Text(stringResource(R.string.delete)) } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + val canImport = !isRecording && working.markedCount > 0 + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + working.name.ifBlank { stringResource(R.string.draft_unnamed) }, + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource( + R.string.draft_editor_subtitle, + working.pointCount, + working.markedCount + ), + style = MaterialTheme.typography.labelSmall + ) + } + }, + navigationIcon = { + IconButton(onClick = { + if (dirty) viewModel.saveDraft(working) + onNavigateBack() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + actions = { + IconButton(onClick = { showSearchDialog = true }) { + Icon( + Icons.Filled.Search, + contentDescription = stringResource(R.string.search_place_action) + ) + } + IconButton( + onClick = { + if (working.markedCount == working.pointCount) { + working = working.copy(markedIndexes = emptyList()) + } else { + working = working.copy( + markedIndexes = working.points.indices.toList() + ) + } + dirty = true + viewModel.saveDraft(working) + }, + enabled = !isRecording && working.pointCount > 0 + ) { + Icon( + if (working.markedCount == working.pointCount && working.pointCount > 0) + Icons.Filled.DeleteSweep + else + Icons.Filled.Bookmarks, + contentDescription = if (working.markedCount == working.pointCount && working.pointCount > 0) + stringResource(R.string.draft_unmark_all) + else + stringResource(R.string.draft_mark_all) + ) + } + IconButton(onClick = { showRenameDialog = true }) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.draft_edit_meta_title)) + } + IconButton(onClick = { showDeleteConfirm = true }) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.draft_delete), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { if (canImport) showImportDialog = true }, + containerColor = if (canImport) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (canImport) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant, + icon = { Icon(Icons.Filled.Download, contentDescription = null) }, + text = { + Text( + if (working.markedCount > 0) + stringResource(R.string.draft_import_button) + " (${working.markedCount})" + else + stringResource(R.string.draft_import_button) + ) + } + ) + } + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + MapView(ctx).apply { + setTileSource(selectedMapProvider.toTileSource()) + setMultiTouchControls(true) + + val center = working.points.firstOrNull()?.let { + GeoPoint(it.latitude, it.longitude) + } ?: GeoPoint(51.1657, 10.4515) + controller.setZoom(15.0) + controller.setCenter(center) + + // Long-press on map: + // - If close to a track point: select that point + // - Else if a point is already selected: move it there + val ev = object : MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint?) = false + override fun longPressHelper(p: GeoPoint?): Boolean { + if (p == null || isRecording) return true + val mvLocal = mapView ?: return true + val nearest = nearestPointWithinPixels( + mvLocal, working.points, p, thresholdPx = 80.0 + ) + if (nearest != null) { + selectedIndex = if (selectedIndex == nearest) null else nearest + return true + } + val sel = selectedIndex + if (sel != null) { + val pts = working.points.toMutableList() + if (sel in pts.indices) { + pts[sel] = pts[sel].copy( + latitude = p.latitude, + longitude = p.longitude + ) + working = working.copy(points = pts) + dirty = true + viewModel.saveDraft(working) + } + } + return true + } + } + overlays.add(MapEventsOverlay(ev)) + + mapView = this + drawDraftOverlays( + mv = this, + draft = working, + selectedIndex = selectedIndex, + polyArgb = tertiary.toArgb(), + pointArgb = primary.toArgb(), + markedArgb = markedColor.toArgb(), + selectedArgb = errorColor.toArgb(), + onPointTapped = { idx -> toggleMark(idx) } + ) + } + }, + update = { mv -> mapView = mv } + ) + + // Always-visible help banner at top so the gesture is discoverable + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + shadowElevation = 3.dp, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 8.dp, start = 12.dp, end = 12.dp) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Icon( + Icons.Filled.BookmarkBorder, + contentDescription = null, + tint = Color(0xFFFFAB00), + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.draft_editor_help), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + // Selection action bar — only when user long-pressed a point + val sel = selectedIndex + if (sel != null && sel in working.points.indices) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + shadowElevation = 4.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 88.dp, start = 12.dp, end = 12.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.draft_point_selected, sel + 1, working.pointCount), + style = MaterialTheme.typography.labelMedium + ) + val isMarked = sel in working.markedIndexes + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { toggleMark(sel) }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isMarked) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.primary + ) + ) { + Icon( + if (isMarked) Icons.Filled.Bookmark else Icons.Filled.BookmarkBorder, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + if (isMarked) + stringResource(R.string.draft_point_unmark) + else + stringResource(R.string.draft_point_mark) + ) + } + Button( + onClick = { deleteSelected() }, + enabled = !isRecording, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.delete)) + } + } + Text( + stringResource(R.string.draft_point_move_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 6.dp) + ) + } + } + } + + // Recording warning banner + if (isRecording) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.94f), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 156.dp, start = 12.dp, end = 12.dp) + ) { + Text( + stringResource(R.string.draft_locked_while_recording), + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + // "Keine Wegpunkte markiert"-Hinweis am FAB + if (!isRecording && working.markedCount == 0 && working.pointCount > 0) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + shadowElevation = 2.dp, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 12.dp, bottom = 88.dp) + ) { + Text( + stringResource(R.string.draft_import_disabled_hint), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + // OSM attribution moved to bottom-left so the import FAB at bottom-right + // cannot overlap the mandatory attribution badge. + OsmAttributionBadge( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 6.dp) + ) + } + } +} + +/** + * Modaler Dialog: Draft als Wegpunkte importieren. + * Nur markierte Punkte werden importiert. Ohne Markierung ist der Import deaktiviert. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DraftImportDialog( + draft: TrackDraft, + tourList: List, + preselectedTour: String, + isRecording: Boolean, + onConfirm: (tour: String) -> Unit, + onDismiss: () -> Unit +) { + var targetTour by remember { mutableStateOf(preselectedTour) } + var menuExpanded by remember { mutableStateOf(false) } + + val markedCount = draft.markedCount + val totalCount = draft.pointCount + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.draft_import_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (isRecording) { + Text( + stringResource(R.string.draft_locked_while_recording), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + if (markedCount > 0) + stringResource(R.string.draft_import_hint_marked, markedCount, totalCount) + else + stringResource(R.string.draft_import_hint_none), + style = MaterialTheme.typography.bodySmall + ) + Text( + stringResource(R.string.draft_import_target_label), + style = MaterialTheme.typography.labelMedium + ) + Box { + OutlinedTextField( + value = targetTour, + onValueChange = { targetTour = it }, + readOnly = false, + label = { Text(stringResource(R.string.tour_name_label)) }, + modifier = Modifier + .fillMaxWidth() + .clickable { menuExpanded = true } + ) + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + tourList.forEach { t -> + DropdownMenuItem( + text = { Text(t) }, + onClick = { + targetTour = t + menuExpanded = false + } + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { onConfirm(targetTour.ifBlank { preselectedTour }) }, + enabled = !isRecording && markedCount > 0 + ) { + Icon(Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.draft_import_confirm, markedCount)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} + +// --------------------------------------------------------------------------- +// Map overlays for draft editor +// --------------------------------------------------------------------------- + +private fun drawDraftOverlays( + mv: MapView, + draft: TrackDraft, + selectedIndex: Int?, + polyArgb: Int, + pointArgb: Int, + markedArgb: Int, + selectedArgb: Int, + searchPin: GeoPoint? = null, + onPointTapped: (Int) -> Unit +) { + val overlays = mv.overlays + overlays.removeAll(overlays.filterIsInstance().toSet()) + overlays.removeAll(overlays.filterIsInstance().toSet()) + + if (draft.points.size >= 2) { + val geoPoints = draft.points.map { GeoPoint(it.latitude, it.longitude) } + val halo = Polyline(mv).apply { + setPoints(geoPoints) + outlinePaint.color = 0xFFFFFFFF.toInt() + outlinePaint.strokeWidth = 28f + outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND + outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND + outlinePaint.alpha = 230 + infoWindow = null + } + val polyline = Polyline(mv).apply { + setPoints(geoPoints) + outlinePaint.color = polyArgb + outlinePaint.strokeWidth = 18f + outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND + outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND + outlinePaint.alpha = 255 + infoWindow = null + } + overlays.add(halo) + overlays.add(polyline) + } + + val markedSet = draft.markedIndexes.toSet() + draft.points.forEachIndexed { idx, p -> + val gp = GeoPoint(p.latitude, p.longitude) + val color = when { + idx == selectedIndex -> selectedArgb + idx in markedSet -> markedArgb + else -> pointArgb + } + val sizePx = when { + idx == selectedIndex -> 96 + idx in markedSet -> 80 + else -> 64 + } + val bm = makePointBitmap(sizePx, color, idx == selectedIndex, idx in markedSet) + val marker = Marker(mv).apply { + position = gp + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, bm) + title = null + snippet = null + subDescription = null + infoWindow = null + setOnMarkerClickListener { _, _ -> + onPointTapped(idx) + true + } + } + overlays.add(marker) + } + + if (searchPin != null) { + val pinBm = makeSearchPinBitmapShared(96) + val pinMarker = Marker(mv).apply { + position = searchPin + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm) + title = null + snippet = null + subDescription = null + infoWindow = null + setOnMarkerClickListener { _, _ -> true } + } + overlays.add(pinMarker) + } + + mv.invalidate() +} + +private fun makePointBitmap( + sizePx: Int, + argb: Int, + selected: Boolean = false, + marked: Boolean = false +): android.graphics.Bitmap { + val size = sizePx.coerceAtLeast(16) + val bm = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bm) + val cx = size / 2f + val cy = size / 2f + val outerR = size / 2f - 2f + + val halo = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + alpha = 220 + } + canvas.drawCircle(cx, cy, outerR, halo) + + val fill = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = argb + } + canvas.drawCircle(cx, cy, outerR - size * 0.12f, fill) + + val ring = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.STROKE + color = 0xFFFFFFFF.toInt() + strokeWidth = (size * 0.10f).coerceAtLeast(3f) + } + canvas.drawCircle(cx, cy, outerR - size * 0.05f, ring) + + if (selected) { + val accent = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.STROKE + color = 0xFF000000.toInt() + strokeWidth = (size * 0.08f).coerceAtLeast(3f) + } + canvas.drawCircle(cx, cy, outerR - size * 0.20f, accent) + val pip = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + } + canvas.drawCircle(cx, cy, size * 0.12f, pip) + } else if (marked) { + // Bookmark-pip indicates marked (waypoint candidate) + val pip = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFF000000.toInt() + } + canvas.drawCircle(cx, cy, size * 0.20f, pip) + val pipInner = android.graphics.Paint().apply { + isAntiAlias = true + style = android.graphics.Paint.Style.FILL + color = 0xFFFFFFFF.toInt() + } + canvas.drawCircle(cx, cy, size * 0.10f, pipInner) + } + return bm +} + +// --------------------------------------------------------------------------- +// OSM Attribution badge +// --------------------------------------------------------------------------- + +@Composable +fun OsmAttributionBadge(modifier: Modifier = Modifier) { + val uriHandler = LocalUriHandler.current + val text = stringResource(R.string.osm_attribution_short) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + modifier = modifier + .clickable { + runCatching { uriHandler.openUri("https://www.openstreetmap.org/copyright") } + } + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy(textDecoration = TextDecoration.Underline), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } +} + +/** + * Finds the index of the nearest track point to [p] in screen-pixel space. + * Returns null if no point is within [thresholdPx] pixels of the press. + */ +private fun nearestPointWithinPixels( + mv: MapView, + points: List, + p: GeoPoint, + thresholdPx: Double +): Int? { + if (points.isEmpty()) return null + val proj = mv.projection + val pressPx = proj.toPixels(p, null) + var bestIdx = -1 + var bestDist = Double.MAX_VALUE + for ((idx, tp) in points.withIndex()) { + val gp = GeoPoint(tp.latitude, tp.longitude) + val px = proj.toPixels(gp, null) + val dx = (px.x - pressPx.x).toDouble() + val dy = (px.y - pressPx.y).toDouble() + val d = sqrt(dx * dx + dy * dy) + if (d < bestDist) { + bestDist = d + bestIdx = idx + } + } + return if (bestIdx >= 0 && bestDist <= thresholdPx) bestIdx else null +} + +private fun draftDistanceMeters(points: List): Double { + if (points.size < 2) return 0.0 + val r = 6_371_000.0 + var total = 0.0 + for (i in 1 until points.size) { + val a = points[i - 1]; val b = points[i] + val dLat = Math.toRadians(b.latitude - a.latitude) + val dLon = Math.toRadians(b.longitude - a.longitude) + val s = sin(dLat / 2).pow(2) + + cos(Math.toRadians(a.latitude)) * cos(Math.toRadians(b.latitude)) * + sin(dLon / 2).pow(2) + total += 2 * r * atan2(sqrt(s), sqrt(1 - s)) + } + return total +} + +fun formatDistanceMeters(points: List): String { + val d = draftDistanceMeters(points) + return if (d >= 1000) "%.1f km".format(d / 1000.0) else "${d.toInt()} m" +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftListScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftListScreen.kt new file mode 100644 index 0000000..cd07ec4 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftListScreen.kt @@ -0,0 +1,213 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BookmarkAdded +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.viewmodel.WaypointViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Archiv aller aufgezeichneten Track-Drafts. + * + * Jede Zeile zeigt Name, Quell-Tour, Anzahl Punkte/Markierungen, Distanz + * und das letzte Bearbeitungsdatum. Tap → öffnet den Karten-Editor. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackDraftListScreen( + viewModel: WaypointViewModel, + onOpenDraft: (String) -> Unit, + onNavigateBack: () -> Unit +) { + val drafts by viewModel.trackDrafts.collectAsState() + val isRecording by viewModel.isRecording.collectAsState() + + Scaffold( + topBar = { + // v2.5.1 — TopAppBar im Surface-Stil, passend zur neuen HomeScreen. + TopAppBar( + title = { Text(stringResource(R.string.draft_list_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary + ) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + if (drafts.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize().padding(24.dp) + ) { + Icon( + Icons.Filled.Timeline, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Spacer(Modifier.padding(8.dp)) + Text( + stringResource(R.string.draft_list_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } else { + LazyColumn( + contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isRecording) { + item { + Card( + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Text( + stringResource(R.string.draft_locked_while_recording), + modifier = Modifier.padding(14.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + items(drafts, key = { it.id }) { draft -> + // v2.5.1 — Card-Optik harmonisiert: weichere Cornern, + // surface-Container (statt SurfaceVariant) und leichte + // Erhebung, passend zu den HomeScreen-Tour-Cards. + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDraft(draft.id) }, + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Map, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + draft.name.ifBlank { stringResource(R.string.draft_unnamed) }, + style = MaterialTheme.typography.titleMedium + ) + val dateStr = SimpleDateFormat( + "dd.MM.yyyy HH:mm", Locale.getDefault() + ).format(Date(draft.updatedAt)) + Text( + stringResource( + R.string.draft_list_subtitle, + draft.sourceTourName, + dateStr + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.padding(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource( + R.string.draft_list_points_dist, + draft.pointCount, + formatDistanceMeters(draft.points) + ), + style = MaterialTheme.typography.bodySmall + ) + if (draft.markedCount > 0) { + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Filled.BookmarkAdded, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = androidx.compose.ui.graphics.Color(0xFFFFAB00) + ) + Spacer(Modifier.width(2.dp)) + Text( + stringResource(R.string.draft_list_marked_count, draft.markedCount), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + IconButton(onClick = { onOpenDraft(draft.id) }) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.map_waypoint_edit)) + } + IconButton(onClick = { viewModel.deleteDraft(draft.id) }) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt new file mode 100644 index 0000000..3c1cfa1 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt @@ -0,0 +1,118 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.VersionNoticeStore +import de.waypointaudio.util.AppVersion +import kotlinx.coroutines.launch + +/** + * Zeigt nach App-Start einen Hinweis-Dialog für die aktuell installierte Version. + * + * Verhalten: + * - Sichtbar, solange `dismissedVersionCode < aktueller versionCode`. + * - Nutzer kann „Für diese Version nicht mehr anzeigen“ aktivieren und mit OK schließen + * → der aktuelle versionCode wird gespeichert. + * - Wird OK ohne Häkchen gedrückt, bleibt der Dialog beim nächsten Start nochmals stehen. + * - Bei Versions-Update (höherer versionCode) erscheint der Dialog automatisch erneut. + */ +@Composable +fun VersionNoticeDialogHost() { + val context = LocalContext.current + val store = remember(context) { VersionNoticeStore(context.applicationContext) } + val versionInfo = remember(context) { AppVersion.get(context) } + + val dismissedCode by store.dismissedVersionCode.collectAsState(initial = -1L) + val scope = rememberCoroutineScope() + + var visible by remember { mutableStateOf(false) } + var dontShowAgain by remember { mutableStateOf(false) } + + // dismissedCode == -1L => initialer Wert (DataStore noch nicht geladen) -> warten + LaunchedEffect(dismissedCode, versionInfo.versionCode) { + if (dismissedCode >= 0L) { + visible = dismissedCode < versionInfo.versionCode + } + } + + if (!visible) return + + AlertDialog( + onDismissRequest = { + // Außerhalb des Dialogs tippen: nur schließen, nicht für künftige Starts ausblenden + visible = false + }, + title = { + Text( + text = stringResource(R.string.version_notice_title), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource( + R.string.version_notice_heading, + versionInfo.display + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.version_notice_body_v2538), + style = MaterialTheme.typography.bodyMedium + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Checkbox( + checked = dontShowAgain, + onCheckedChange = { dontShowAgain = it } + ) + Text( + text = stringResource(R.string.version_notice_dont_show_again), + style = MaterialTheme.typography.bodyMedium + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + if (dontShowAgain) { + scope.launch { + store.setDismissedVersionCode(versionInfo.versionCode) + } + } + visible = false + }) { + Text(stringResource(R.string.version_notice_ok)) + } + } + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WakeLockSettingsDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/WakeLockSettingsDialog.kt new file mode 100644 index 0000000..fcc66af --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/WakeLockSettingsDialog.kt @@ -0,0 +1,141 @@ +package de.waypointaudio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.waypointaudio.R +import de.waypointaudio.data.WakeLockMode +import de.waypointaudio.data.WakeLockSettings +import de.waypointaudio.data.WakeLockSettingsStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Dialog zur Auswahl des Wake-Lock-Modus (Display aktiv halten). + * + * Zeigt drei Optionen und zeigt beim ersten Wechsel auf einen aktiven Modus + * einen einmaligen Akku-Hinweis. Der gewählte Modus wird sofort persistiert. + */ +@Composable +fun WakeLockSettingsDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val store = remember { WakeLockSettingsStore(context.applicationContext) } + val scope = remember { CoroutineScope(Dispatchers.Main) } + + var settings by remember { mutableStateOf(WakeLockSettings()) } + var loaded by remember { mutableStateOf(false) } + var showBatteryHint by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + settings = store.currentSettings() + loaded = true + } + + fun selectMode(newMode: WakeLockMode) { + if (newMode == settings.mode) return + val previousMode = settings.mode + settings = settings.copy(mode = newMode) + scope.launch { + withContext(Dispatchers.IO) { + store.setMode(newMode) + } + // Einmaliger Akku-Hinweis: nur beim erstmaligen Aktivieren (von NEVER zu aktiv) + if (newMode != WakeLockMode.NEVER && + previousMode == WakeLockMode.NEVER && + !settings.batteryHintShown + ) { + showBatteryHint = true + withContext(Dispatchers.IO) { + store.markBatteryHintShown() + } + settings = settings.copy(batteryHintShown = true) + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.wake_lock_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(R.string.wake_lock_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (loaded) { + WakeLockOption( + label = stringResource(R.string.wake_lock_mode_never), + selected = settings.mode == WakeLockMode.NEVER, + onClick = { selectMode(WakeLockMode.NEVER) } + ) + WakeLockOption( + label = stringResource(R.string.wake_lock_mode_active_tour), + selected = settings.mode == WakeLockMode.ACTIVE_TOUR, + onClick = { selectMode(WakeLockMode.ACTIVE_TOUR) } + ) + WakeLockOption( + label = stringResource(R.string.wake_lock_mode_foreground), + selected = settings.mode == WakeLockMode.APP_FOREGROUND, + onClick = { selectMode(WakeLockMode.APP_FOREGROUND) } + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.about_close)) } + } + ) + + if (showBatteryHint) { + AlertDialog( + onDismissRequest = { showBatteryHint = false }, + title = { Text(stringResource(R.string.wake_lock_battery_hint_title)) }, + text = { Text(stringResource(R.string.wake_lock_battery_hint_msg)) }, + confirmButton = { + TextButton(onClick = { showBatteryHint = false }) { + Text(stringResource(R.string.wake_lock_battery_hint_ok)) + } + } + ) + } +} + +@Composable +private fun WakeLockOption( + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt index f5a2d78..c1d4304 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt @@ -12,9 +12,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +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.Schedule +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.* @@ -26,9 +31,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import de.waypointaudio.R +import de.waypointaudio.data.AudioClip import de.waypointaudio.data.PlaybackMode import de.waypointaudio.data.TourPlaybackDefaults import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.effectiveClips +import de.waypointaudio.data.withClips +import de.waypointaudio.license.LicenseFeatureAccess +import de.waypointaudio.license.LicenseStatusProvider import java.text.SimpleDateFormat import java.util.Date @@ -69,11 +79,23 @@ fun WaypointEditDialog( prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME, tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(), onConfirm: (Waypoint) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + /** + * v2.5.15 — Optionaler Callback: kopiert den bearbeiteten Wegpunkt + * in eine andere Tour. Wenn null oder es keine alternative Tour gibt, + * wird die Aktion ausgeblendet. Der Aufrufer ist für ID-Neuvergabe + * und Persistenz zuständig (siehe [de.waypointaudio.viewmodel.WaypointViewModel.copyWaypointToTour]). + */ + onCopyToOtherTour: ((targetTour: String) -> Unit)? = null, ) { val context = LocalContext.current 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) + // Coordinate pre-fill: GPS position takes priority for new waypoints val initialLat = when { waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude) @@ -91,8 +113,18 @@ fun WaypointEditDialog( var latStr by remember { mutableStateOf(initialLat) } var lngStr by remember { mutableStateOf(initialLng) } var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") } - var soundUri by remember { mutableStateOf(waypoint?.soundUri ?: "") } - var soundName by remember { mutableStateOf(waypoint?.soundName ?: "") } + + // 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 val initialTour = when { @@ -144,7 +176,8 @@ fun WaypointEditDialog( } } - // Audio file picker + // Audio file picker — v2.5.12: erzeugt einen neuen Clip oder ersetzt + // die Audio-Quelle eines bestehenden Clips. Steuerung über [replaceTargetIndex]. val audioPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> @@ -155,9 +188,31 @@ fun WaypointEditDialog( android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION ) } - soundUri = uri.toString() - soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':') + val pickedUri = uri.toString() + val pickedName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':') ?: uri.toString().takeLast(30) + val tgt = replaceTargetIndex + if (tgt != null && tgt in clips.indices) { + val old = clips[tgt] + clips[tgt] = old.copy( + uri = pickedUri, + displayFileName = pickedName, + // Titel nur dann automatisch übernehmen, wenn noch keiner gesetzt war + title = old.title.ifBlank { pickedName } + ) + } else { + clips.add( + AudioClip( + title = pickedName, + uri = pickedUri, + displayFileName = pickedName, + isActive = true, + isPrimary = clips.isEmpty(), + orderIndex = clips.size + ) + ) + } + replaceTargetIndex = null } } @@ -319,6 +374,23 @@ fun WaypointEditDialog( 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 @@ -362,33 +434,236 @@ fun WaypointEditDialog( modifier = Modifier.fillMaxWidth() ) - // Audio file selection - OutlinedButton( - onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) }, - modifier = Modifier.fillMaxWidth() - ) { - Icon( - Icons.Filled.AudioFile, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.choose_sound)) + // --------------------------------------------------------------- + // 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, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = LicenseFeatureAccess.MSG_MULTI_CLIP, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) + } } - if (soundName.isNotBlank()) { - Text( - text = soundName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 2 - ) - } else { + if (clips.isEmpty()) { Text( text = stringResource(R.string.no_sound_selected), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f) + 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 + Surface( + tonalElevation = 1.dp, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Kopfzeile: Position + (primary)-Marker + Row( + verticalAlignment = Alignment.CenterVertically, + 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() + ) { + // 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/*")) + }) { + Icon( + Icons.Filled.SwapHoriz, + contentDescription = "Audio ersetzen", + modifier = Modifier.size(20.dp) + ) + } + // 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)) + } + }) { + Text( + text = "Als Hauptclip festlegen", + style = MaterialTheme.typography.bodySmall + ) + } + } + } // end clipEditable + } + } + } + } + + // "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)) @@ -702,6 +977,9 @@ fun WaypointEditDialog( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) ) } + + // v2.5.16 — Kopiersektion wurde in den oberen Tour-Bereich + // verschoben und ist dort gut sichtbar. } }, confirmButton = { @@ -738,26 +1016,36 @@ fun WaypointEditDialog( maxPlayCountError || scheduleRangeError if (!hasError) { - onConfirm( - (waypoint ?: Waypoint()).copy( - name = name.trim(), - latitude = lat!!, - longitude = lng!!, - radiusMeters = radius!!, - soundUri = soundUri, - soundName = soundName, - tourName = safeTourName, - // Playback rules - playbackMode = playbackMode, - maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null, - playCount = currentPlayCount, - scheduleEnabled = scheduleEnabled, - scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null, - scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null, - allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null, - allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null - ) + // 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(), + latitude = lat!!, + longitude = lng!!, + radiusMeters = radius!!, + tourName = safeTourName, + // Playback rules + playbackMode = playbackMode, + maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null, + playCount = currentPlayCount, + scheduleEnabled = scheduleEnabled, + scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null, + scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null, + allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null, + 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) + } + onConfirm(withAudio) } }) { Text(stringResource(R.string.save)) @@ -770,3 +1058,107 @@ fun WaypointEditDialog( } ) } + +/** + * v2.5.16 — Kopiersektion für den Bearbeiten-Dialog. + * + * Stellt zwei Aktionen bereit: + * - "In andere Tour kopieren" mit Drop-down-Auswahl (alle Touren außer der + * aktuellen). + * - "In dieser Tour duplizieren" als zweite Aktion. Hängt eine 1:1-Kopie + * ans Ende der aktuellen Tour. Auch nutzbar, wenn nur eine Tour existiert. + * + * Wird vom [WaypointEditDialog] nur eingebunden, wenn ein bestehender + * Wegpunkt bearbeitet wird und ein onCopyToOtherTour-Callback vorhanden ist + * — also nie in der Player-/Playback-Ansicht. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WaypointCopySection( + currentTour: String, + existingTours: List, + onCopy: (targetTour: String) -> Unit, +) { + val effectiveCurrent = currentTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val otherTours = remember(existingTours, effectiveCurrent) { + 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) + ) + + 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) + ) { + 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") + } + } + } 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“)") + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt index 3044fbc..0bcf6cc 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt @@ -2,12 +2,22 @@ package de.waypointaudio.ui import android.Manifest import android.content.pm.PackageManager +import android.widget.Toast import java.util.Locale import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.Image +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.filled.* @@ -15,21 +25,46 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlin.math.abs +import kotlin.math.roundToInt import androidx.core.content.ContextCompat import de.waypointaudio.R import de.waypointaudio.data.MusicSourceType import de.waypointaudio.data.PlaybackMode import de.waypointaudio.data.TourAudioSettings +import de.waypointaudio.data.TourCover +import de.waypointaudio.data.TourCoverIcon +import de.waypointaudio.data.TourCoverPalette import de.waypointaudio.data.TourPlaybackDefaults import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.activeClips +import de.waypointaudio.data.effectiveClips +import de.waypointaudio.data.primarySoundUri +import de.waypointaudio.data.WaypointListViewSettings +import de.waypointaudio.data.WaypointListViewSettingsStore import de.waypointaudio.data.WaypointMusicBehavior +import de.waypointaudio.license.LicenseFeatureAccess +import de.waypointaudio.license.LicenseStatusProvider import de.waypointaudio.service.MusicPlaybackState import de.waypointaudio.viewmodel.WaypointViewModel +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope /** * Hauptbildschirm: Listet Wegpunkte einer Tour auf, mit Tab-Leiste für Touren. @@ -49,7 +84,9 @@ import de.waypointaudio.viewmodel.WaypointViewModel @Composable fun WaypointListScreen( viewModel: WaypointViewModel, - onNavigateToMap: () -> Unit + onNavigateToMap: () -> Unit, + onNavigateToDrafts: () -> Unit = {}, + onNavigateToExplorer: () -> Unit = {} ) { val context = LocalContext.current val waypoints by viewModel.waypoints.collectAsState() @@ -68,6 +105,10 @@ fun WaypointListScreen( val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState() val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() + // v2.5.14 — Aktuell laufender Playable (für Clip-N/M und Detail-Sheet). + val currentPlayable by viewModel.currentPlayable.collectAsState() + val playbackProgress by viewModel.playbackProgress.collectAsState() + // Begleitmusik state val musicSettings by viewModel.musicSettings.collectAsState() val musicPlaying by viewModel.musicPlaying.collectAsState() @@ -87,12 +128,58 @@ fun WaypointListScreen( viewModel.manualPlaylist().size } + // Direkte Reihenfolge-Bearbeitung (v2.0.2): jede Wegpunkt-Karte besitzt + // einen Ziehgriff (DragIndicator) am linken Rand. Drag-and-Drop ist aktiv, + // sobald weder GPS-Dienst noch Track-Aufzeichnung läuft. Kein separater + // Modus nötig. + val isRecording by viewModel.isRecording.collectAsState() + val reorderBlocked = serviceRunning || isRecording + + // Compact Tour-Reorder Dialog – kompakter Ersatz für die alte + // "Reihenfolge bearbeiten"-Vollzeile. + var showTourReorderDialog by remember { mutableStateOf(false) } + LaunchedEffect(reorderBlocked) { + if (reorderBlocked && showTourReorderDialog) showTourReorderDialog = false + } + + // v2.5.8 — Wiedergabe-zuerst-UI: Standard ist der Wiedergabemodus. Drag- + // Handles, Edit-/Lösch-Icons, Koordinaten und technische Datei-IDs sind + // dort versteckt. Über den Topbar-Button (oder den Overflow-Eintrag + // „Bearbeiten") lässt sich der Bearbeitungsmodus aktivieren — dann sind + // alle bisherigen Funktionen wie in v2.5.7 unverändert sichtbar. Default + // nach App-Start: Wiedergabe (kein Persist; sicherer Default). + var editMode by remember { mutableStateOf(false) } + var showAddDialog by remember { mutableStateOf(false) } var editingWaypoint by remember { mutableStateOf(null) } var deleteCandidate by remember { mutableStateOf(null) } + // v2.5.11 — Clip-Detailansicht (Bottom Sheet). Hält die ID, damit + // Updates am Wiedergabe-/Activestate live durchschlagen. + var clipDetailWaypointId by remember { mutableStateOf(null) } + // v2.5.15 — Wenn true, wird die Detailansicht als Vollbild-Dialog + // statt als Bottom-Sheet angezeigt. Minimize stellt den Sheet-Modus + // wieder her, ohne den Player-Zustand zu verlieren. + var clipDetailFullscreen by remember { mutableStateOf(false) } var showOverflowMenu by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) } + var showWakeLockDialog by remember { mutableStateOf(false) } var showBegleitmusikDialog by remember { mutableStateOf(false) } + var showBackupDialog by remember { mutableStateOf(false) } + // v2.5.7 — Datensicherung-Sheet: bündelt JSON/GPX/Backup-Aktionen. + var showDataSafetySheet by remember { mutableStateOf(false) } + // Selections survive across dialog open/close + var backupOptTours by remember { mutableStateOf(true) } + var backupOptDrafts by remember { mutableStateOf(true) } + var backupOptMedia by remember { mutableStateOf(true) } + var backupOptSettings by remember { mutableStateOf(true) } + val infoMessage by viewModel.infoMessage.collectAsState() + + // Backup-Import State + val backupPreview by viewModel.backupPreview.collectAsState() + var importOptTours by remember { mutableStateOf(true) } + var importOptDrafts by remember { mutableStateOf(true) } + var importOptMedia by remember { mutableStateOf(true) } + var importOptSettings by remember { mutableStateOf(true) } // "Neue Tour" Dialog var showNewTourDialog by remember { mutableStateOf(false) } @@ -100,8 +187,33 @@ fun WaypointListScreen( var showRenameTourDialog by remember { mutableStateOf(false) } // "Tour löschen" Bestätigung var showDeleteTourDialog by remember { mutableStateOf(false) } + // v2.5.15 — "Tour duplizieren" Dialog (Variante anlegen) + var showDuplicateTourDialog by remember { mutableStateOf(false) } // "Tour-Zähler" Dialog var showTourCounterDialog by remember { mutableStateOf(false) } + // v2.5.5 — "Tour-Cover ändern" Dialog + var showTourCoverDialog by remember { mutableStateOf(false) } + + // v2.5.4 — Sichtbarkeitsoptionen für PTT, Atmo, Tour-Zähler. + // Wird über das Overflow-Menü "Ansicht anpassen…" konfiguriert, + // persistiert in [WaypointListViewSettingsStore]. + val viewSettingsStore = remember(context) { + WaypointListViewSettingsStore(context.applicationContext) + } + val viewSettings by viewSettingsStore.settings.collectAsState(initial = WaypointListViewSettings()) + val viewSettingsScope = rememberCoroutineScope() + var showCustomizeDialog by remember { mutableStateOf(false) } + + // v2.5.32 — Feature-Gates: PTT und Tour-Zähler erfordern Profi-Zugang. + // Compose-State reagiert reaktiv auf Lizenzimport. + val licenseStatus = LicenseStatusProvider.status + val canPtt = LicenseFeatureAccess.canUsePtt(licenseStatus) + val canTourCounter = LicenseFeatureAccess.canUseTourCounter(licenseStatus) + + // PTT-Aufnahme-Dialoge (v2.1.0) + var showPttRecordingsLibrary by remember { mutableStateOf(false) } + var assignTarget by remember { mutableStateOf(null) } + val lastRecording by viewModel.pttManager.lastRecording.collectAsState() // Launcher für nachträgliche Standortberechtigung val locationPermissionLauncher = rememberLauncherForActivityResult( @@ -125,28 +237,54 @@ fun WaypointListScreen( // File pickers / document launchers // ----------------------------------------------------------------------- - val exportJsonLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") - ) { uri -> - uri?.let { viewModel.exportJson(it) } - } - - val importJsonLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { viewModel.importJson(it) } - } - val exportGpxLauncher = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/gpx+xml") ) { uri -> uri?.let { viewModel.exportGpx(it) } } + // v2.5.18 — Additiver GPX-Import. + // + // Statt direkt zu importieren (was in v2.5.17 ein versehentliches + // `repository.saveAll(imported)` ausgelöst und damit alle bestehenden + // Wegpunkte ersetzt hat), merken wir uns die gewählte URI und zeigen + // einen Dialog an, in dem der Nutzer "Als neue Tour" oder "Zu bestehender + // Tour hinzufügen" auswählen kann. Abbrechen verändert nichts. + var pendingGpxImportUri by remember { mutableStateOf(null) } + var pendingGpxSuggestedName by remember { mutableStateOf("") } + val importGpxLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> - uri?.let { viewModel.importGpx(it) } + if (uri != null) { + pendingGpxImportUri = uri + pendingGpxSuggestedName = viewModel.suggestTourNameForGpx(uri) + } + } + + // Backup-ZIP Export (SAF). MIME application/zip. + val createBackupZipLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + if (uri != null) { + viewModel.exportBackupZip( + uri = uri, + toursAndWaypoints = backupOptTours, + trackDrafts = backupOptDrafts, + mediaAndAtmo = backupOptMedia, + settings = backupOptSettings + ) + } + } + + // Backup-ZIP Import (SAF). Akzeptiert ZIP und beliebige (Fallback) MIME-Typen, + // damit auch von OS-Filer-Apps gelieferte Backups (oktett-stream) lesbar sind. + val importBackupZipLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + viewModel.loadBackupPreview(uri) + } } // ----------------------------------------------------------------------- @@ -174,24 +312,61 @@ fun WaypointListScreen( } } + // ----------------------------------------------------------------------- + // v2.5.13 — Hintergrundwiedergabe-Hinweis. Erscheint einmalig pro + // Version, sobald die Wiedergabe wirklich relevant wird (manueller + // Play läuft oder Wegpunkt-Dienst aktiv) und die App nicht von der + // Akkuoptimierung ausgenommen ist. Blockiert die Wiedergabe nicht. + // ----------------------------------------------------------------------- + BackgroundReliabilityDialogHost( + triggerCheck = manualPlaying || + singlePlayingActive || + serviceRunning + ) + // ----------------------------------------------------------------------- // Scaffold // ----------------------------------------------------------------------- Scaffold( topBar = { + // v2.5.1 — Design-Harmonisierung: TopAppBar nutzt nun Surface-Hintergrund + // (wie die HomeScreen) statt einer kräftigen Primary-Fläche. + // Aktionen behalten Primary-Tint und bleiben gut sichtbar. TopAppBar( title = { - Text( - text = "GPS2Audio", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_launcher), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.size(26.dp) + ) + Column { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + Text( + text = selectedTour, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - actionIconContentColor = MaterialTheme.colorScheme.onPrimary + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary ), actions = { // Dienst-Schalter @@ -208,15 +383,42 @@ fun WaypointListScreen( ) } - // Kartenansicht - IconButton(onClick = onNavigateToMap) { + // v2.2.2: Explorer ist primärer Karten-Arbeitsbereich. + // Hier können Wegpunkte direkt gesetzt, bearbeitet und gelöscht werden. + IconButton(onClick = onNavigateToExplorer) { Icon( Icons.Filled.Map, - contentDescription = stringResource(R.string.map_view) + contentDescription = stringResource(R.string.explorer_open) ) } - // Overflow-Menü (Import/Export + Tourenverwaltung) + // v2.5.8 — Edit-Toggle direkt in der TopAppBar. Im + // Wiedergabemodus (Default) sind Bearbeitungs-Aktionen + // versteckt; ein Tap aktiviert den Bearbeitungsmodus mit + // allen v2.5.7-Funktionen (Drag&Drop, Stift, Löschen, + // Audiofile zuweisen). Doppelklick/erneuter Tap kehrt + // zurück zur Wiedergabe. + IconButton(onClick = { editMode = !editMode }) { + Icon( + imageVector = if (editMode) Icons.Filled.Done else Icons.Filled.Edit, + contentDescription = if (editMode) + "Bearbeitungsmodus beenden" + else + "Bearbeitungsmodus starten", + tint = if (editMode) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.primary + ) + } + + // v2.5.7 — Der separate Track-Editor-Icon-Button wurde aus + // der Touransicht entfernt. Track-/Routenbearbeitung läuft + // weiterhin über den Explorer (Draft öffnen → Editor) und + // erreicht so dieselbe Funktionalität wie zuvor, ohne die + // Touransicht doppelt zu belasten. + + // Overflow-Menü (Tourenverwaltung + Datensicherung) Box { IconButton(onClick = { showOverflowMenu = true }) { Icon(Icons.Filled.MoreVert, contentDescription = "Mehr") @@ -225,6 +427,29 @@ fun WaypointListScreen( expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false } ) { + // v2.5.8 — Wiedergabe-/Bearbeitungsmodus auch als + // Menüeintrag (zur Toolbar-Schaltfläche redundant). + DropdownMenuItem( + text = { + Text( + if (editMode) + "Bearbeitungsmodus beenden" + else + "Bearbeiten…" + ) + }, + leadingIcon = { + Icon( + if (editMode) Icons.Filled.Done else Icons.Filled.Edit, + null + ) + }, + onClick = { + showOverflowMenu = false + editMode = !editMode + } + ) + HorizontalDivider() // --- Tour-Aktionen --- DropdownMenuItem( text = { Text(stringResource(R.string.tour_new)) }, @@ -242,6 +467,15 @@ fun WaypointListScreen( showRenameTourDialog = true } ) + // v2.5.15 — Tour als Variante duplizieren + DropdownMenuItem( + text = { Text("Tour duplizieren…") }, + leadingIcon = { Icon(Icons.Filled.ContentCopy, null) }, + onClick = { + showOverflowMenu = false + showDuplicateTourDialog = true + } + ) if (selectedTour == Waypoint.DEFAULT_TOUR_NAME) { // Standard-Tour kann nicht gelöscht werden – Eintrag deaktiviert DropdownMenuItem( @@ -283,38 +517,33 @@ fun WaypointListScreen( ) } HorizontalDivider() - // --- Import/Export --- + // Explorer (v2.2.2 — Haupt-Karten-Arbeitsbereich) DropdownMenuItem( - text = { Text(stringResource(R.string.export_json)) }, - leadingIcon = { Icon(Icons.Filled.FileDownload, null) }, + text = { Text(stringResource(R.string.explorer_open)) }, + leadingIcon = { Icon(Icons.Filled.Map, null) }, onClick = { showOverflowMenu = false - exportJsonLauncher.launch("wegpunkte.json") - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.import_json)) }, - leadingIcon = { Icon(Icons.Filled.FileUpload, null) }, - onClick = { - showOverflowMenu = false - importJsonLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) + onNavigateToExplorer() } ) + // v2.5.7 — Track-Editor-Menüpunkt aus der Touransicht + // entfernt: Drafts werden über den Explorer geöffnet, + // dort steht der Vollbild-Editor weiterhin bereit. HorizontalDivider() + // v2.5.7 — Datensicherung: bündelt JSON-Export/-Import, + // GPX-Export/-Import und Backup-Erstellung/-Import in + // einem einzigen, sauber gegliederten Sheet. Die + // ActivityResult-Launcher (`exportJsonLauncher`, + // `importJsonLauncher`, `exportGpxLauncher`, + // `importGpxLauncher`, `createBackupZipLauncher`, + // `importBackupZipLauncher`) sind unverändert und + // werden hier wiederverwendet. DropdownMenuItem( - text = { Text(stringResource(R.string.export_gpx)) }, - leadingIcon = { Icon(Icons.Filled.FileDownload, null) }, + text = { Text("Datensicherung") }, + leadingIcon = { Icon(Icons.Filled.Backup, null) }, onClick = { showOverflowMenu = false - exportGpxLauncher.launch("wegpunkte.gpx") - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.import_gpx)) }, - leadingIcon = { Icon(Icons.Filled.FileUpload, null) }, - onClick = { - showOverflowMenu = false - importGpxLauncher.launch(arrayOf("application/gpx+xml", "text/xml", "application/xml", "*/*")) + showDataSafetySheet = true } ) HorizontalDivider() @@ -328,6 +557,31 @@ fun WaypointListScreen( } ) HorizontalDivider() + // v2.5.4 — Ansicht anpassen: PTT/Atmo/Tour-Zähler ein-/ausblenden + DropdownMenuItem( + text = { Text("Ansicht anpassen…") }, + leadingIcon = { Icon(Icons.Filled.Tune, null) }, + onClick = { + showOverflowMenu = false + showCustomizeDialog = true + } + ) + // v2.5.19 — „Touren ordnen" aus der Touransicht entfernt. + // Der Einstieg ist jetzt im Hauptmenü der Audio-Bibliothek + // (HomeScreen) verfügbar, wo er logisch zu Hause ist + // (Sortierung über die gesamte Tourliste, nicht pro Tour). + // Die Funktion selbst (TourReorderDialog, moveTour-Logik) + // bleibt unverändert erhalten. + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_wake_lock)) }, + leadingIcon = { Icon(Icons.Filled.LightMode, null) }, + onClick = { + showOverflowMenu = false + showWakeLockDialog = true + } + ) + HorizontalDivider() DropdownMenuItem( text = { Text(stringResource(R.string.menu_about)) }, leadingIcon = { Icon(Icons.Filled.Info, null) }, @@ -342,36 +596,40 @@ fun WaypointListScreen( ) }, floatingActionButton = { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // GPS-Wegpunkt FAB - SmallFloatingActionButton( - onClick = onGpsButtonClicked, - containerColor = MaterialTheme.colorScheme.secondary + // v2.5.8 — Wiedergabe-zuerst-UI: Im Wiedergabemodus zeigen wir keine + // FABs für "Wegpunkt hinzufügen" / "GPS-Wegpunkt" mehr. Beide + // Aktionen sind im Bearbeitungsmodus weiterhin vollständig + // verfügbar (Plus-FAB + GPS-FAB). Auch im Explorer bleiben sie + // erreichbar (dort ohnehin der primäre Karten-Arbeitsbereich). + if (editMode) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (locationLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onSecondary, - strokeWidth = 2.dp - ) - } else { - Icon( - Icons.Filled.MyLocation, - contentDescription = stringResource(R.string.add_waypoint_from_location), - tint = MaterialTheme.colorScheme.onSecondary - ) + SmallFloatingActionButton( + onClick = onGpsButtonClicked, + containerColor = MaterialTheme.colorScheme.secondary + ) { + if (locationLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onSecondary, + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Filled.MyLocation, + contentDescription = stringResource(R.string.add_waypoint_from_location), + tint = MaterialTheme.colorScheme.onSecondary + ) + } + } + FloatingActionButton( + onClick = { showAddDialog = true }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_waypoint)) } - } - - // Neuer Wegpunkt FAB - FloatingActionButton( - onClick = { showAddDialog = true }, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_waypoint)) } } } @@ -382,171 +640,339 @@ fun WaypointListScreen( .padding(innerPadding) ) { // ------------------------------------------------------------------- - // Tab-Leiste für Touren + // v2.5.4 — Tour-Hero: stellt Tour-Namen und Wegpunkt-Kontext visuell + // in den Vordergrund (Tour-/Audio-App-Anmutung statt Dashboard). + // v2.5.5 — Hero zeigt das tour-spezifische Cover (falls gewählt) und + // bietet "Cover ändern…" über einen kleinen Stift-Button rechts an. // ------------------------------------------------------------------- - if (tourList.isNotEmpty()) { - // Clamp selectedTabIndex so it is always within [0, tourList.size-1]. - // indexOf returns -1 when selectedTour is not yet in tourList (async gap), - // so we coerce into the valid range to prevent ScrollableTabRow from crashing. - val tabIndex = tourList.indexOf(selectedTour) - .coerceIn(0, tourList.size - 1) - ScrollableTabRow( - selectedTabIndex = tabIndex, - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.primary, - edgePadding = 8.dp + val tourCovers by viewModel.tourCovers.collectAsState() + val activeCover = tourCovers[selectedTour] + TourHeroSection( + tourName = selectedTour, + waypointCount = filteredWaypoints.size, + activeCount = filteredWaypoints.count { it.isActive }, + cover = activeCover, + onChangeCover = { showTourCoverDialog = true } + ) + + // v2.5.7 — Die horizontale ScrollableTabRow wurde aus der primären + // Touransicht entfernt. Sie hat das Layout dominiert und doppelt mit + // der Audio-Bibliothek (HomeScreen) kollidiert, die ohnehin der + // primäre Einstieg für Tourwechsel ist. Tourwechsel bleibt vollständig + // erreichbar: + // • Tour-Cards auf der Home-Startseite (HomeScreen) → Tour aktiv + + // direkter Sprung in diese Touransicht. + // • Im Overflow-Menü „Touren ordnen…" lässt sich die Tourliste + // weiterhin neu ordnen, „Tour umbenennen/löschen/neu" bleibt + // unverändert verfügbar. + // • „Bearbeitungsmodus" (v2.5.19, vormals „Klassische Ansicht") + // auf der Startseite ruft dieselbe + // Touransicht auf — Touren werden durch Auswahl auf der + // Startseite (oder über das Menü) gewechselt. + + // ------------------------------------------------------------------- + // Scrollbarer Hauptinhalt: PTT, Sort/Tour-Reihenfolge, Tour-Zähler, + // Wegpunkt-Tracks, Atmo, Manueller Player. Dienst-Status-Banner bleibt + // als Overlay über dem Listen-Bereich. + // ------------------------------------------------------------------- + val mainScrollState = rememberScrollState() + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(mainScrollState) ) { - tourList.forEachIndexed { index, tour -> - Tab( - selected = tabIndex == index, - onClick = { viewModel.selectTour(tour) }, - text = { - Text( - text = tour, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + // --------------------------------------------------------------- + // v2.5.4 — Tour-Werkzeuge: kompakter, optionaler Bereich. + // PTT, Atmo und Tour-Zähler lassen sich über "Ansicht anpassen…" + // ein- und ausblenden. Funktional unverändert; wenn eingeblendet, + // verhalten sich die Module exakt wie bisher. + // v2.5.32 — PTT und Tour-Zähler zusätzlich durch Feature-Gate + // geschützt: nur anzeigen wenn Profi-Zugang vorhanden. + // Bestehende Daten (PTT-Aufnahmen, Zählerwerte) werden nie gelöscht. + // --------------------------------------------------------------- + val anyToolVisible = (viewSettings.showTourCounter && canTourCounter) || + (viewSettings.showPtt && canPtt) || + viewSettings.showAtmo + if (anyToolVisible) { + ToolsSectionHeader( + onCustomize = { showCustomizeDialog = true } ) } - } - } - // ------------------------------------------------------------------- - // Tour-Zähler-Karte - // ------------------------------------------------------------------- - TourCounterCard( - filteredWaypoints = filteredWaypoints, - onOpenDialog = { showTourCounterDialog = true } - ) + if (viewSettings.showTourCounter && canTourCounter) { + TourCounterCard( + filteredWaypoints = filteredWaypoints, + onOpenDialog = { showTourCounterDialog = true } + ) + } - // ------------------------------------------------------------------- - // Live / PTT Karte (zwischen Atmo-Bereich und Wegpunkt-Tracks) - // ------------------------------------------------------------------- - LivePttCard( - pttManager = viewModel.pttManager - ) + if (viewSettings.showPtt && canPtt) { + LivePttCard( + pttManager = viewModel.pttManager, + onOpenRecordings = { showPttRecordingsLibrary = true } + ) + } - // ------------------------------------------------------------------- - // Wegpunkt-Tracks Überschrift - // ------------------------------------------------------------------- - Text( - text = stringResource(R.string.waypoint_tracks_heading), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) - ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) + if (viewSettings.showAtmo) { + BegleitmusikMiniPlayer( + settings = musicSettings, + playbackState = musicPlaybackState, + errorMessage = musicError, + onPlayPause = { viewModel.toggleMusicPlayback() }, + onStop = { viewModel.stopMusicPlayback() }, + onPrevious = { viewModel.musicPrevious() }, + onNext = { viewModel.musicNext() }, + onConfigure = { showBegleitmusikDialog = true }, + onClearError = { viewModel.clearMusicError() } + ) + } - // ------------------------------------------------------------------- - // Wegpunkt-Liste (gefiltert nach gewählter Tour) - // ------------------------------------------------------------------- - Box(modifier = Modifier.weight(1f)) { - if (filteredWaypoints.isEmpty()) { - Text( - text = if (waypoints.isEmpty()) - stringResource(R.string.no_waypoints) - else - stringResource(R.string.tour_no_waypoints), - modifier = Modifier.align(Alignment.Center), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - ) - } else { - LazyColumn( - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - // Extra bottom padding so last card is not hidden behind player bar - bottom = 8.dp - ), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - items(filteredWaypoints, key = { it.id }) { wp -> - val isThisSingleLoaded = singlePlayingWaypointId == wp.id - val isThisSinglePlaying = isThisSingleLoaded && singlePlayingActive - WaypointCard( - waypoint = wp, - onEdit = { editingWaypoint = wp }, - onDelete = { deleteCandidate = wp }, - onToggleActive = { - viewModel.upsert(wp.copy(isActive = !wp.isActive)) - }, - onPlaySingle = if (wp.soundUri.isNotBlank()) { - { viewModel.manualPlaySingle(wp) } - } else null, - isSinglePlaying = isThisSinglePlaying, - isSingleLoaded = isThisSingleLoaded + // --------------------------------------------------------------- + // Manuelle Wiedergabe-Leiste – ab v2.0.0 direkt unter Atmo + // --------------------------------------------------------------- + val remainingLabel = remember(playbackProgress) { + if (playbackProgress.durationMs > 0L && + (playbackProgress.isPlaying || playbackProgress.positionMs > 0L)) { + "Verbleibend " + de.waypointaudio.data.formatRemaining( + playbackProgress.remainingMs ) + } else null + } + ManualPlayerBar( + hasPlayable = playableCount > 0, + isPlaying = if (singlePlayingWaypointId != null) singlePlayingActive else manualPlaying, + currentName = manualCurrentName, + onPrevious = { viewModel.manualPrevious() }, + onPlayPause = { viewModel.manualPlayPause() }, + onNext = { viewModel.manualNext() }, + isSingleMode = singlePlayingWaypointId != null, + remainingLabel = remainingLabel + ) + + // --------------------------------------------------------------- + // v2.5.5 — Wegpunkte-Sektion: prominenter Titel + Zähler-Chip. + // Der Sortier-Hinweis wandert in eine eigene, zweite Zeile als + // kompakter, ruhiger Chip. So kann er nie die Titelspalte + // zerquetschen (Screenshot-Bug bei aktivem GPS-Dienst). + // --------------------------------------------------------------- + Spacer(Modifier.height(if (anyToolVisible) 6.dp else 2.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // v2.5.8 — Im Wiedergabemodus heißt der Listenkopf + // „Wiedergabe-Playlist", im Bearbeitungsmodus weiterhin + // „Wegpunkt-Tracks". + Text( + text = if (editMode) + stringResource(R.string.waypoint_tracks_heading) + else + "Wiedergabe-Playlist", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!editMode) { + // Mode-Chip: bestätigt visuell den Modus. + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(50), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "Wiedergabe", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Spacer(Modifier.width(6.dp)) + } + if (filteredWaypoints.isNotEmpty()) { + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f) + ) { + Text( + text = "${filteredWaypoints.count { it.isActive }}/${filteredWaypoints.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp) + ) + } } } + // v2.5.5 — Sortier-Hinweis als eigener, kompakter Chip in einer + // separaten Zeile. Stört das Titel-Layout nicht mehr und bleibt + // auch bei aktivem GPS-Dienst sauber lesbar. + if (reorderBlocked && filteredWaypoints.size > 1) { + Surface( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 4.dp) + .fillMaxWidth(), + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.reorder_blocked_hint_short), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + } + + // --------------------------------------------------------------- + // Wegpunkt-Liste (gefiltert nach gewählter Tour) + // Inline-Column statt LazyColumn, damit alle Items im äußeren + // verticalScroll erreichbar sind (insb. up/down im Sort-Modus). + // --------------------------------------------------------------- + if (filteredWaypoints.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (waypoints.isEmpty()) + stringResource(R.string.no_waypoints) + else + stringResource(R.string.tour_no_waypoints), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } else { + // Drag-and-Drop Reorder (v2.0.2) + // --------------------------------- + // Eine eigene `DragReorderState` verfolgt den gerade + // gezogenen Index, die aufsummierte Y-Verschiebung und die + // gemessenen Item-Höhen. Während eines aktiven Drags wird + // das gezogene Item per graphicsLayer verschoben und über + // einen z-Index nach vorn gebracht; die übrigen Items + // weichen mit ihrer eigenen Höhe aus, sodass die neue + // Zielposition sichtbar ist. Beim Loslassen ruft die UI + // `viewModel.moveWaypoint` mit dem Endindex auf, was die + // bestehende `saveAll`-Logik persistiert. + // + // Der Drag startet ausschließlich am dedizierten Griff + // (DragIndicator-Icon links), damit normales vertikales + // Scrollen nicht versehentlich zum Verschieben führt. + // Zusätzlich ist Long-Press auf der gesamten Karte als + // Alternative aktiviert. + val reorderState = rememberDragReorderState( + itemCount = filteredWaypoints.size, + onMove = { from, to -> viewModel.moveWaypoint(from, to) } + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + filteredWaypoints.forEachIndexed { index, wp -> + key(wp.id) { + val isThisSingleLoaded = singlePlayingWaypointId == wp.id + val isThisSinglePlaying = isThisSingleLoaded && singlePlayingActive + WaypointCard( + waypoint = wp, + onEdit = { editingWaypoint = wp }, + onDelete = { deleteCandidate = wp }, + onToggleActive = { + viewModel.upsert(wp.copy(isActive = !wp.isActive)) + }, + onPlaySingle = if (wp.activeClips().isNotEmpty()) { + { viewModel.manualPlaySingle(wp) } + } else null, + isSinglePlaying = isThisSinglePlaying, + isSingleLoaded = isThisSingleLoaded, + // v2.5.11 — Tippen auf die Karte öffnet die + // Detailansicht (nur im Wiedergabe-Modus). + onOpenDetail = { clipDetailWaypointId = wp.id }, + // v2.5.8 — Drag&Drop, Edit-/Lösch-/Datei- + // Aktionen nur im Bearbeitungsmodus aktiv. + reorderEnabled = !reorderBlocked && editMode, + editMode = editMode, + positionNumber = index + 1, + index = index, + reorderState = reorderState + ) + } + } + } + } + + // Bottom-Spacer, damit der zwei-FAB-Stack nichts verdeckt. + // v2.2.1: kompakter (vorher 160dp). + Spacer(Modifier.height(120.dp)) } - // Dienst-Status-Banner + // v2.5.5 — Dienst-Status-Banner: ruhige Statuszeile am unteren Rand. + // Bewusst sehr flach + sekundäre Containerfarbe, damit die Liste + // optisch nicht in zwei Hälften zerfällt und das Banner bei + // aktivem GPS keinen Layoutbruch erzeugt. if (serviceRunning) { Surface( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter), - color = MaterialTheme.colorScheme.primaryContainer + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f), + tonalElevation = 2.dp ) { Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Filled.LocationOn, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(14.dp) ) Spacer(Modifier.width(6.dp)) Text( "GPS-Dienst aktiv", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } } } - - // ------------------------------------------------------------------- - // Begleitmusik Mini-Player (sichtbar unterhalb der Wegpunktliste) - // ------------------------------------------------------------------- - BegleitmusikMiniPlayer( - settings = musicSettings, - playbackState = musicPlaybackState, - errorMessage = musicError, - onPlayPause = { viewModel.toggleMusicPlayback() }, - onStop = { viewModel.stopMusicPlayback() }, - onPrevious = { viewModel.musicPrevious() }, - onNext = { viewModel.musicNext() }, - onConfigure = { showBegleitmusikDialog = true }, - onClearError = { viewModel.clearMusicError() } - ) - - // ------------------------------------------------------------------- - // Manuelle Wiedergabe-Leiste - // ------------------------------------------------------------------- - ManualPlayerBar( - hasPlayable = playableCount > 0, - // In single mode the bar also reflects play/pause state of the single item - isPlaying = if (singlePlayingWaypointId != null) singlePlayingActive else manualPlaying, - currentName = manualCurrentName, - onPrevious = { viewModel.manualPrevious() }, - // In single mode the bar's play/pause button stops single mode and goes playlist - onPlayPause = { viewModel.manualPlayPause() }, - onNext = { viewModel.manualNext() }, - isSingleMode = singlePlayingWaypointId != null - ) } } @@ -584,7 +1010,10 @@ fun WaypointListScreen( viewModel.upsert(updated) editingWaypoint = null }, - onDismiss = { editingWaypoint = null } + onDismiss = { editingWaypoint = null }, + onCopyToOtherTour = { targetTour -> + viewModel.copyWaypointToTour(wp.id, targetTour) + } ) } @@ -616,6 +1045,50 @@ fun WaypointListScreen( AboutDialog(onDismiss = { showAboutDialog = false }) } + // ── PTT-Aufnahme: Hintergrund-Autosave (v2.1.2) ────────────────────────── + // Nach Beenden der PTT-Aufnahme wird KEIN Bestätigungsdialog mehr gezeigt. + // Die Aufnahme ist bereits in der PTT-Aufnahmen-Bibliothek gespeichert; wir + // bestätigen das nur kurz und unaufdringlich per Toast und räumen den State + // sofort wieder auf, damit der Workflow nicht unterbrochen wird. + LaunchedEffect(lastRecording?.id) { + val rec = lastRecording ?: return@LaunchedEffect + val durationLabel = run { + val totalSec = (rec.durationMs / 1000).coerceAtLeast(0) + "%d:%02d".format(totalSec / 60, totalSec % 60) + } + Toast.makeText( + context, + context.getString(R.string.ptt_recording_autosaved_toast, rec.displayName, durationLabel), + Toast.LENGTH_SHORT + ).show() + viewModel.pttManager.clearLastRecording() + } + + if (showPttRecordingsLibrary) { + PttRecordingsLibraryDialog( + pttManager = viewModel.pttManager, + viewModel = viewModel, + onAssign = { rec -> + assignTarget = rec + showPttRecordingsLibrary = false + }, + onDismiss = { showPttRecordingsLibrary = false } + ) + } + + assignTarget?.let { rec -> + WaypointChooserDialog( + recording = rec, + viewModel = viewModel, + onAssigned = { _ -> assignTarget = null }, + onDismiss = { assignTarget = null } + ) + } + + if (showWakeLockDialog) { + WakeLockSettingsDialog(onDismiss = { showWakeLockDialog = false }) + } + if (showBegleitmusikDialog) { BegleitmusikDialog( tourName = selectedTour, @@ -678,6 +1151,19 @@ fun WaypointListScreen( ) } + // v2.5.15 — Tour duplizieren / als Variante anlegen + if (showDuplicateTourDialog) { + DuplicateTourDialog( + sourceTour = selectedTour, + existingTours = tourList, + onConfirm = { newName -> + val ok = viewModel.duplicateTour(selectedTour, newName, selectAfter = true) + if (ok) showDuplicateTourDialog = false + }, + onDismiss = { showDuplicateTourDialog = false } + ) + } + // Tour-Zähler Dialog if (showTourCounterDialog) { val tourWaypoints = filteredWaypoints @@ -709,6 +1195,1328 @@ fun WaypointListScreen( } ) } + + infoMessage?.let { msg -> + AlertDialog( + onDismissRequest = { viewModel.clearInfo() }, + title = { Text("Backup") }, + text = { Text(msg) }, + confirmButton = { + TextButton(onClick = { viewModel.clearInfo() }) { + Text("OK") + } + } + ) + } + + // Beim Erscheinen einer Backup-Vorschau: Sektionen vorausgewählt + // an verfügbar gemeldeten Sektionen ausrichten. + LaunchedEffect(backupPreview) { + backupPreview?.let { + importOptTours = it.sectionToursAndWaypoints + importOptDrafts = it.sectionTrackDrafts + importOptMedia = it.sectionMediaAndAtmo + importOptSettings = it.sectionSettings + } + } + + backupPreview?.let { preview -> + BackupImportDialog( + preview = preview, + optTours = importOptTours && preview.sectionToursAndWaypoints, + optDrafts = importOptDrafts && preview.sectionTrackDrafts, + optMedia = importOptMedia && preview.sectionMediaAndAtmo, + optSettings = importOptSettings && preview.sectionSettings, + availTours = preview.sectionToursAndWaypoints, + availDrafts = preview.sectionTrackDrafts, + availMedia = preview.sectionMediaAndAtmo, + availSettings = preview.sectionSettings, + onToggleTours = { importOptTours = it }, + onToggleDrafts = { importOptDrafts = it }, + onToggleMedia = { importOptMedia = it }, + onToggleSettings = { importOptSettings = it }, + onConfirm = { + viewModel.confirmBackupImport( + toursAndWaypoints = importOptTours && preview.sectionToursAndWaypoints, + trackDrafts = importOptDrafts && preview.sectionTrackDrafts, + mediaAndAtmo = importOptMedia && preview.sectionMediaAndAtmo, + settings = importOptSettings && preview.sectionSettings + ) + }, + onDismiss = { viewModel.clearBackupPreview() } + ) + } + + if (showTourReorderDialog) { + TourReorderDialog( + tours = tourList, + onMove = { from, to -> viewModel.moveTour(from, to) }, + onDismiss = { showTourReorderDialog = false } + ) + } + + // v2.5.7 — Datensicherung-Sheet: gebündelter Zugang zu GPX/Backup. + // v2.5.18 — JSON-Export/-Import aus der sichtbaren UI entfernt; das + // ZIP-Backup übernimmt diese Rolle vollständig. Alte JSON-Dateien können + // intern weiterhin gelesen werden (Backup-/Migration-Kompatibilität). + if (showDataSafetySheet) { + DataSafetySheet( + onExportGpx = { + showDataSafetySheet = false + exportGpxLauncher.launch("wegpunkte.gpx") + }, + onImportGpx = { + showDataSafetySheet = false + importGpxLauncher.launch( + arrayOf("application/gpx+xml", "text/xml", "application/xml", "*/*") + ) + }, + onCreateBackup = { + showDataSafetySheet = false + showBackupDialog = true + }, + onImportBackup = { + showDataSafetySheet = false + importBackupZipLauncher.launch( + arrayOf("application/zip", "application/x-zip-compressed", "*/*") + ) + }, + onDismiss = { showDataSafetySheet = false } + ) + } + + // v2.5.18 — GPX-Import-Dialog (additiv). + pendingGpxImportUri?.let { uri -> + GpxImportDialog( + existingTours = tourList, + suggestedTourName = pendingGpxSuggestedName, + onImportAsNewTour = { name -> + val target = uri + pendingGpxImportUri = null + viewModel.importGpxAsNewTour(target, name) + }, + onAppendToExisting = { tour -> + val target = uri + pendingGpxImportUri = null + viewModel.importGpxIntoExistingTour(target, tour) + }, + onDismiss = { pendingGpxImportUri = null } + ) + } + + if (showBackupDialog) { + BackupDialog( + optTours = backupOptTours, + optDrafts = backupOptDrafts, + optMedia = backupOptMedia, + optSettings = backupOptSettings, + onToggleTours = { backupOptTours = it }, + onToggleDrafts = { backupOptDrafts = it }, + onToggleMedia = { backupOptMedia = it }, + onToggleSettings = { backupOptSettings = it }, + onConfirm = { + showBackupDialog = false + val ts = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US) + .format(java.util.Date()) + createBackupZipLauncher.launch("gps2audio_backup_$ts.zip") + }, + onDismiss = { showBackupDialog = false } + ) + } + + // v2.5.4 — Ansicht anpassen: Atmo, PTT, Tour-Zähler ein-/ausblenden. + if (showCustomizeDialog) { + WaypointListCustomizeDialog( + settings = viewSettings, + canPtt = canPtt, + canTourCounter = canTourCounter, + onSetShowAtmo = { value -> + viewSettingsScope.launch { viewSettingsStore.setShowAtmo(value) } + }, + onSetShowPtt = { value -> + viewSettingsScope.launch { viewSettingsStore.setShowPtt(value) } + }, + onSetShowTourCounter = { value -> + viewSettingsScope.launch { viewSettingsStore.setShowTourCounter(value) } + }, + onDismiss = { showCustomizeDialog = false } + ) + } + + // v2.5.5 — Tour-Cover ändern (Icon + Farbverlauf). + // v2.5.7 — Zusätzlich: eigenes Bild als Cover wählen (Android Photo Picker). + // Der Launcher ruft den Picker auf; das Bild wird in + // [WaypointViewModel.setTourCoverImageFromUri] app-intern persistiert. + val pickCoverImageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + viewModel.setTourCoverImageFromUri(uri) + showTourCoverDialog = false + } + } + if (showTourCoverDialog) { + val tourCovers by viewModel.tourCovers.collectAsState() + TourCoverDialog( + tourName = selectedTour, + current = tourCovers[selectedTour], + onConfirm = { cover -> + viewModel.setTourCoverForCurrent(cover) + showTourCoverDialog = false + }, + onClear = { + viewModel.clearTourCoverForCurrent() + showTourCoverDialog = false + }, + onPickImage = { + pickCoverImageLauncher.launch( + androidx.activity.result.PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + }, + onRemoveImage = { + viewModel.clearTourCoverImageForCurrent() + }, + onDismiss = { showTourCoverDialog = false } + ) + } + + // v2.5.14 — Clip-Detailansicht (Bottom Sheet) für einzelnen Wegpunkt. + // Vor/Zurück navigieren in der flachen Playable-Sequence der Tour + // (Clip→Clip→Wegpunkt), nicht mehr nur zwischen Wegpunkten. Sobald der + // aktuell laufende Playable den Wegpunkt wechselt, wird das Detail-Sheet + // mitgezogen — der Nutzer sieht den Wegpunkt, dessen Clip gerade läuft. + clipDetailWaypointId?.let { activeId -> + val wp = filteredWaypoints.firstOrNull { it.id == activeId } + if (wp == null) { + // Wegpunkt wurde zwischenzeitlich gelöscht/verschoben — sauber schließen. + LaunchedEffect(activeId) { clipDetailWaypointId = null } + } else { + // Falls Auto-Advance den aktiven Playable in einen anderen Wegpunkt + // weitergezogen hat, springt die Detailansicht entsprechend mit. + LaunchedEffect(currentPlayable?.waypointId) { + val cp = currentPlayable + if (cp != null && cp.waypointId != activeId && + filteredWaypoints.any { it.id == cp.waypointId }) { + clipDetailWaypointId = cp.waypointId + } + } + + val positionInTour = filteredWaypoints.indexOfFirst { it.id == activeId } + 1 + val effClips = wp.effectiveClips() + val currentClipId = currentPlayable + ?.takeIf { it.waypointId == wp.id } + ?.clipId + val tracks = effClips.map { c -> + WaypointDetailUiState.TrackPreview( + title = c.effectiveTitle.ifBlank { + wp.name.ifBlank { "Audio-Datei" } + }, + uri = c.uri, + isPrimary = c.isPrimary, + isCurrent = currentClipId != null && c.id == currentClipId, + ) + } + + val isThisSingleLoaded = singlePlayingWaypointId == wp.id + val isThisSinglePlaying = isThisSingleLoaded && singlePlayingActive + val playlistMatchesAndPlaying = manualPlaying && + currentPlayable?.waypointId == wp.id && !isThisSingleLoaded + val isCurrentlyPlaying = isThisSinglePlaying || playlistMatchesAndPlaying + + // Position des aktuell aktiven Clips innerhalb des Waypoints + // (1-basiert, 0 = "nicht eindeutig zuzuordnen"). + val currentClipIndex = currentPlayable + ?.takeIf { it.waypointId == wp.id } + ?.clipIndex + ?: 0 + val totalClips = effClips.size + + val state = WaypointDetailUiState( + waypoint = wp, + tourName = selectedTour, + positionInTour = positionInTour, + totalInTour = filteredWaypoints.size, + isCurrentlyPlaying = isCurrentlyPlaying, + tracks = tracks, + currentClipIndex = currentClipIndex, + totalClips = totalClips, + progress = playbackProgress, + ) + + // v2.5.14 — Flache Playable-Sequence der Tour. Vor/Zurück navigieren + // *clipweise* statt nur waypointweise. Aktiv ist Next, solange mind. + // ein weiterer Playable existiert (gleicher oder nächster Wegpunkt). + val sequence = remember(filteredWaypoints) { + viewModel.playableSequence() + } + val currentSeqIdx = when { + currentPlayable != null -> sequence.indexOfFirst { + it.compoundId == currentPlayable!!.compoundId + } + else -> sequence.indexOfFirst { it.waypointId == wp.id } + } + val hasPrev = currentSeqIdx > 0 + val hasNext = currentSeqIdx in 0 until (sequence.size - 1) + + val onPlayPauseAction: () -> Unit = { + if (tracks.isNotEmpty() || wp.soundUri.isNotBlank()) { + viewModel.manualPlaySingle(wp) + } + } + val onPrevAction: (() -> Unit)? = if (hasPrev) { + { + viewModel.manualPrevious() + val newCp = viewModel.currentPlayable.value + if (newCp != null) clipDetailWaypointId = newCp.waypointId + } + } else null + val onNextAction: (() -> Unit)? = if (hasNext) { + { + viewModel.manualNext() + val newCp = viewModel.currentPlayable.value + if (newCp != null) clipDetailWaypointId = newCp.waypointId + } + } else null + val onDismissAction: () -> Unit = { + clipDetailWaypointId = null + clipDetailFullscreen = false + } + + if (clipDetailFullscreen) { + ClipDetailFullscreen( + state = state, + onPlayPause = onPlayPauseAction, + onPrevious = onPrevAction, + onNext = onNextAction, + onStop = { viewModel.manualStop() }, + onDismiss = onDismissAction, + onMinimize = { clipDetailFullscreen = false }, + ) + } else { + ClipDetailSheet( + state = state, + onPlayPause = onPlayPauseAction, + onPrevious = onPrevAction, + onNext = onNextAction, + onStop = { viewModel.manualStop() }, + onDismiss = onDismissAction, + onMaximize = { clipDetailFullscreen = true }, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// v2.5.4 — Tour-Hero: prominenter Header mit Tour-Icon, Tourname und Wegpunkt- +// Kontext. Soll die Ansicht von „technischem Dashboard" hin zu „Tour-/Audio- +// Bibliothek" verschieben. Farbwelt: weiches surfaceContainerHigh + dezenter +// Tour-Akzent (Teal-Palette wie HomeScreen). +// +// v2.5.5 — Optionales [TourCover] (Icon + Farbverlauf) tritt anstelle der +// deterministischen Default-Optik. Bleibt das Cover null, wird wie bisher das +// vom Tournamen abgeleitete Icon und der Tour-Akzent gerendert. +// --------------------------------------------------------------------------- +@Composable +private fun TourHeroSection( + tourName: String, + waypointCount: Int, + activeCount: Int, + cover: TourCover? = null, + onChangeCover: () -> Unit = {} +) { + val accent = tourAccentColor(tourName) + val fallbackIcon = tourIconFor(tourName) + val coverIcon = cover?.let { tourCoverIconVector(it.icon) } ?: fallbackIcon + val coverBrush = coverGradient(cover, accent) + // v2.5.7 — Bild-Cover hat Vorrang über Icon/Palette. Dateipfad wird hier + // einmalig zu einem ImageBitmap dekodiert (LRU-cached via remember(key)). + val imageBitmap = rememberTourCoverBitmap(cover?.imageUri) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Cover-Kachel: Bild > Verlauf+Icon. Klick öffnet die Cover-Auswahl. + Box( + modifier = Modifier + .size(46.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) + .background(coverBrush) + .clickable(onClick = onChangeCover), + contentAlignment = Alignment.Center + ) { + if (imageBitmap != null) { + androidx.compose.foundation.Image( + bitmap = imageBitmap, + contentDescription = "Cover ändern", + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } else { + Icon( + imageVector = coverIcon, + contentDescription = "Cover ändern", + tint = if (cover != null) Color.White else accent, + modifier = Modifier.size(26.dp) + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Tour", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = tourName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (waypointCount == 0) { + "Noch keine Wegpunkte" + } else { + "$activeCount/$waypointCount Wegpunkte aktiv" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + // v2.5.5 — Stift-Button für "Cover ändern". Bewusst klein und ruhig. + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onChangeCover, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Cover ändern", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// v2.5.5 — Tour-Cover Auswahl-Dialog +// --------------------------------------------------------------------------- + +@Composable +private fun TourCoverDialog( + tourName: String, + current: TourCover?, + onConfirm: (TourCover) -> Unit, + onClear: () -> Unit, + onPickImage: () -> Unit = {}, + onRemoveImage: () -> Unit = {}, + onDismiss: () -> Unit +) { + var selectedIcon by remember(current) { + mutableStateOf(current?.icon ?: TourCoverIcon.HEADPHONES) + } + var selectedPalette by remember(current) { + mutableStateOf(current?.palette ?: TourCoverPalette.TEAL) + } + val previewBrush = coverGradient(TourCover(selectedIcon, selectedPalette), tourAccentColor(tourName)) + // v2.5.7 — vorhandenes Cover-Bild für die Vorschau dekodieren. + val currentImageBitmap = rememberTourCoverBitmap(current?.imageUri) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Tour-Cover wählen") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Live-Vorschau + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp)) + .background(previewBrush), + contentAlignment = Alignment.Center + ) { + if (currentImageBitmap != null) { + androidx.compose.foundation.Image( + bitmap = currentImageBitmap, + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } else { + Icon( + imageVector = tourCoverIconVector(selectedIcon), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(36.dp) + ) + } + } + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = tourName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (currentImageBitmap != null) + "Eigenes Bild als Cover" + else + "Vorschau", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // v2.5.7 — Eigenes Bild als Cover. + Divider() + Text("Eigenes Bild", style = MaterialTheme.typography.labelMedium) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalButton( + onClick = onPickImage, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Image, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("Bild auswählen") + } + if (current?.imageUri != null) { + TextButton(onClick = onRemoveImage) { + Text("Bild entfernen") + } + } + } + Text( + text = "Wird zentral zugeschnitten und app-intern gespeichert (keine zusätzlichen Berechtigungen).", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Divider() + Text("Symbol", style = MaterialTheme.typography.labelMedium) + // Symbol-Raster (kompakt, 6 pro Zeile etwa) + val icons = TourCoverIcon.entries + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + icons.chunked(6).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + row.forEach { ic -> + val sel = ic == selectedIcon + Box( + modifier = Modifier + .size(40.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) + .background( + if (sel) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + .border( + width = if (sel) 1.5.dp else 0.dp, + color = if (sel) MaterialTheme.colorScheme.primary + else Color.Transparent, + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp) + ) + .clickable { selectedIcon = ic }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = tourCoverIconVector(ic), + contentDescription = tourCoverIconLabel(ic), + tint = if (sel) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + } + } + } + } + + Divider() + Text("Farbe", style = MaterialTheme.typography.labelMedium) + val palettes = TourCoverPalette.entries + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + palettes.chunked(4).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + row.forEach { pal -> + val sel = pal == selectedPalette + val brush = coverGradient(TourCover(selectedIcon, pal), tourAccentColor(tourName)) + Box( + modifier = Modifier + .size(width = 60.dp, height = 36.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp)) + .background(brush) + .border( + width = if (sel) 2.dp else 0.dp, + color = if (sel) MaterialTheme.colorScheme.primary + else Color.Transparent, + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp) + ) + .clickable { selectedPalette = pal }, + contentAlignment = Alignment.Center + ) { + if (sel) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + onConfirm(TourCover(selectedIcon, selectedPalette)) + }) { Text("Übernehmen") } + }, + dismissButton = { + Row { + if (current != null) { + TextButton(onClick = onClear) { Text("Zurücksetzen") } + } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + } + ) +} + +// Liefert den Brush für ein gegebenes Cover. Ist [cover] null, wird ein sehr +// weicher Verlauf aus dem deterministischen Tour-Akzent zurückgegeben – damit +// die Hero-Kachel auch ohne Cover-Auswahl visuell zur App passt. +@Composable +private fun coverGradient(cover: TourCover?, accent: Color): Brush { + return if (cover == null) { + Brush.linearGradient( + colors = listOf(accent.copy(alpha = 0.20f), accent.copy(alpha = 0.10f)) + ) + } else { + val (a, b) = tourCoverPaletteColors(cover.palette) + Brush.linearGradient(colors = listOf(a, b)) + } +} + +private fun tourCoverPaletteColors(palette: TourCoverPalette): Pair = when (palette) { + TourCoverPalette.TEAL -> Color(0xFF0B7B82) to Color(0xFF26867E) + TourCoverPalette.OCEAN -> Color(0xFF1E3C72) to Color(0xFF2A5298) + TourCoverPalette.SUNSET -> Color(0xFFFF6E7F) to Color(0xFFBFE9FF) + TourCoverPalette.FOREST -> Color(0xFF1F4037) to Color(0xFF99F2C8) + TourCoverPalette.BERRY -> Color(0xFF7B1FA2) to Color(0xFFE91E63) + TourCoverPalette.DUSK -> Color(0xFF3A1C71) to Color(0xFFD76D77) + TourCoverPalette.SAND -> Color(0xFFCB956D) to Color(0xFFEACDA3) + TourCoverPalette.MIDNIGHT -> Color(0xFF0F2027) to Color(0xFF2C5364) +} + +private fun tourCoverIconVector(icon: TourCoverIcon): androidx.compose.ui.graphics.vector.ImageVector = when (icon) { + TourCoverIcon.HEADPHONES -> Icons.Filled.Headphones + TourCoverIcon.MUSIC_NOTE -> Icons.Filled.MusicNote + TourCoverIcon.HIKING -> Icons.Filled.Hiking + TourCoverIcon.PARK -> Icons.Filled.Park + TourCoverIcon.CITY -> Icons.Filled.LocationCity + TourCoverIcon.BIKE -> Icons.Filled.DirectionsBike + TourCoverIcon.WAVES -> Icons.Filled.Waves + TourCoverIcon.EXPLORE -> Icons.Filled.Explore + TourCoverIcon.LANDSCAPE -> Icons.Filled.Landscape + TourCoverIcon.CAMPAIGN -> Icons.Filled.Campaign + TourCoverIcon.NIGHTLIFE -> Icons.Filled.Nightlife + TourCoverIcon.THEATER_COMEDY -> Icons.Filled.TheaterComedy +} + +private fun tourCoverIconLabel(icon: TourCoverIcon): String = when (icon) { + TourCoverIcon.HEADPHONES -> "Kopfhörer" + TourCoverIcon.MUSIC_NOTE -> "Note" + TourCoverIcon.HIKING -> "Wandern" + TourCoverIcon.PARK -> "Park" + TourCoverIcon.CITY -> "Stadt" + TourCoverIcon.BIKE -> "Rad" + TourCoverIcon.WAVES -> "Wasser" + TourCoverIcon.EXPLORE -> "Entdecken" + TourCoverIcon.LANDSCAPE -> "Landschaft" + TourCoverIcon.CAMPAIGN -> "Audioguide" + TourCoverIcon.NIGHTLIFE -> "Nachtleben" + TourCoverIcon.THEATER_COMEDY -> "Theater" +} + +// --------------------------------------------------------------------------- +// v2.5.4 — Sektionskopf "Tour-Werkzeuge". Optisch ruhig, mit Hinweis auf +// "Ansicht anpassen…". Wird nur dargestellt, wenn mindestens ein optionales +// Modul (PTT, Atmo, Tour-Zähler) eingeblendet ist. +// --------------------------------------------------------------------------- +@Composable +private fun ToolsSectionHeader(onCustomize: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Tour-Werkzeuge", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = onCustomize, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Icon( + Icons.Filled.Tune, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "Anpassen", + style = MaterialTheme.typography.labelSmall + ) + } + } +} + +// --------------------------------------------------------------------------- +// v2.5.4 — Dialog "Ansicht anpassen": Atmo, PTT, Tour-Zähler ein-/ausblenden. +// Persistiert in [WaypointListViewSettingsStore]. +// --------------------------------------------------------------------------- +@Composable +private fun WaypointListCustomizeDialog( + settings: WaypointListViewSettings, + canPtt: Boolean = true, + canTourCounter: Boolean = true, + onSetShowAtmo: (Boolean) -> Unit, + onSetShowPtt: (Boolean) -> Unit, + onSetShowTourCounter: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Ansicht anpassen") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Wähle, welche Tour-Werkzeuge in dieser Ansicht erscheinen. " + + "Ausgeblendete Module bleiben funktional erhalten und " + + "können jederzeit wieder eingeblendet werden.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(6.dp)) + WaypointListCustomizeRow( + label = "Tour-Zähler", + description = if (canTourCounter) "Wiedergaben, Modus-Übersicht der Tour" + else "Profi-Funktion · GPS2Audio Profi-Lizenz erforderlich", + checked = settings.showTourCounter, + enabled = canTourCounter, + onCheckedChange = onSetShowTourCounter + ) + WaypointListCustomizeRow( + label = "PTT-Modul", + description = if (canPtt) "Live-PTT-Karte mit Aufnahmen-Zugang" + else "Profi-Funktion · GPS2Audio Profi-Lizenz erforderlich", + checked = settings.showPtt, + enabled = canPtt, + onCheckedChange = onSetShowPtt + ) + WaypointListCustomizeRow( + label = "Atmo-Player", + description = "Begleitmusik / Stream-Mini-Player", + checked = settings.showAtmo, + onCheckedChange = onSetShowAtmo + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Fertig") } + } + ) +} + +@Composable +private fun WaypointListCustomizeRow( + label: String, + description: String, + checked: Boolean, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + description, + style = MaterialTheme.typography.labelSmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + Switch( + checked = checked && enabled, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled + ) + } +} + +// --------------------------------------------------------------------------- +// v2.5.4 — Tour-Akzent & Icon-Heuristik. Spiegelt die in HomeScreen.kt +// genutzte Teal-Palette wider, damit Hero- und Home-Karten konsistent wirken. +// --------------------------------------------------------------------------- +private fun tourAccentColor(tourName: String): Color { + val palette = listOf( + Color(0xFF0B7B82), + Color(0xFF1C5461), + Color(0xFF004F54), + Color(0xFF4DB6AC), + Color(0xFF26867E), + Color(0xFF15838C), + Color(0xFF3A8A82), + Color(0xFF0E5A66) + ) + val hash = abs(tourName.hashCode()) + return palette[hash % palette.size] +} + +/** + * v2.5.7 — Lädt ein TourCover-Bild von einem app-internen Pfad als + * [androidx.compose.ui.graphics.ImageBitmap]. Cached per Pfad via [remember], + * sodass das Bild beim Recomposing nicht jedes Mal neu dekodiert wird. + * + * Robust: + * - Pfad blank/null → null + * - Datei nicht vorhanden → null (UI fällt auf Icon/Palette zurück) + * - Decoding-Fehler → null (kein Crash) + */ +@Composable +internal fun rememberTourCoverBitmap(path: String?): androidx.compose.ui.graphics.ImageBitmap? { + if (path.isNullOrBlank()) return null + return remember(path) { + runCatching { + val file = java.io.File(path) + if (!file.exists()) return@runCatching null + val bm = android.graphics.BitmapFactory.decodeFile(path) + ?: return@runCatching null + bm.asImageBitmap() + }.getOrNull() + } +} + +private fun tourIconFor(name: String): androidx.compose.ui.graphics.vector.ImageVector { + val lower = name.lowercase() + return when { + "wald" in lower || "natur" in lower || "park" in lower -> Icons.Filled.Park + "stadt" in lower || "city" in lower || "altstadt" in lower -> Icons.Filled.LocationCity + "berg" in lower || "alpen" in lower || "wander" in lower -> Icons.Filled.Hiking + "rad" in lower || "bike" in lower || "fahrrad" in lower -> Icons.Filled.DirectionsBike + "see" in lower || "fluss" in lower || "wasser" in lower -> Icons.Filled.Waves + "audio" in lower || "musik" in lower -> Icons.Filled.MusicNote + else -> Icons.Filled.Headphones + } +} + +// --------------------------------------------------------------------------- +// v2.5.7 — Datensicherung-Sheet +// +// Bündelt JSON-Export/-Import, GPX-Export/-Import sowie Backup- +// Erstellung/-Import in einem klar gegliederten Modal-Sheet. Ersetzt die +// sechs einzelnen Overflow-Menüpunkte, die in v2.5.6 die Menüsicht zerlegt +// haben. +// +// Wichtig: dieses Sheet öffnet nur die Dialoge bzw. die bereits in +// [WaypointListScreen] registrierten [rememberLauncherForActivityResult]- +// Launcher (`exportJsonLauncher`, `importJsonLauncher`, `exportGpxLauncher`, +// `importGpxLauncher`, `createBackupZipLauncher`, `importBackupZipLauncher`). +// Die SAF/Document-Pflege bleibt damit unverändert; das Sheet ist reine UI. +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DataSafetySheet( + onExportGpx: () -> Unit, + onImportGpx: () -> Unit, + onCreateBackup: () -> Unit, + onImportBackup: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Datensicherung", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Touren, Wegpunkte und Begleitmusik sichern oder austauschen. " + + "Das ZIP-Backup deckt alle Daten ab; GPX dient dem Austausch mit " + + "anderen Tools und ist seit v2.5.18 strikt additiv.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + DataSafetySection(label = "Komplett-Backup (empfohlen)") { + DataSafetyAction( + icon = Icons.Filled.Archive, + title = "Backup erstellen", + description = "Alle Touren, Wegpunkte, Tour-Cover und Mediendateien in eine ZIP-Datei sichern.", + onClick = onCreateBackup + ) + DataSafetyAction( + icon = Icons.Filled.Unarchive, + title = "Backup importieren", + description = "Vorhandenes GPS2Audio-Backup (.zip) einspielen. Sektionen lassen sich vor dem Import wählen.", + onClick = onImportBackup + ) + } + DataSafetySection(label = "GPX (Geräte-/Tool-Austausch)") { + DataSafetyAction( + icon = Icons.Filled.FileDownload, + title = "Als GPX exportieren", + description = "Wegpunkte als wegpunkte.gpx speichern – kompatibel mit OsmAnd, Garmin u. a.", + onClick = onExportGpx + ) + DataSafetyAction( + icon = Icons.Filled.FileUpload, + title = "GPX importieren", + description = "Wegpunkte additiv übernehmen: als neue Tour oder zu bestehender Tour hinzufügen. Bestehende Daten bleiben erhalten.", + onClick = onImportGpx + ) + } + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { Text("Schließen") } + } + } +} + +// --------------------------------------------------------------------------- +// v2.5.18 — GPX-Import-Dialog (additiv) +// +// Schutz vor Datenverlust: vor dem Schreiben muss der Nutzer explizit +// auswählen, ob die importierten Wegpunkte in eine neue oder in eine +// bestehende Tour übernommen werden sollen. Abbrechen verändert nichts. +// --------------------------------------------------------------------------- +@Composable +private fun GpxImportDialog( + existingTours: List, + suggestedTourName: String, + onImportAsNewTour: (String) -> Unit, + onAppendToExisting: (String) -> Unit, + onDismiss: () -> Unit +) { + var mode by remember { mutableStateOf(GpxImportMode.NEW_TOUR) } + var newTourName by remember { mutableStateOf(suggestedTourName) } + var selectedExisting by remember(existingTours) { + mutableStateOf(existingTours.firstOrNull().orEmpty()) + } + val canConfirm = when (mode) { + GpxImportMode.NEW_TOUR -> newTourName.trim().isNotEmpty() + GpxImportMode.EXISTING -> selectedExisting.isNotEmpty() + } + val canChooseExisting = existingTours.isNotEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("GPX importieren") }, + text = { + Column { + Text( + text = "Wo sollen die Wegpunkte aus der GPX-Datei landen? " + + "Bestehende Touren und Wegpunkte bleiben in jedem Fall erhalten.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { mode = GpxImportMode.NEW_TOUR } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = mode == GpxImportMode.NEW_TOUR, + onClick = { mode = GpxImportMode.NEW_TOUR } + ) + Spacer(Modifier.width(4.dp)) + Text("Als neue Tour importieren", style = MaterialTheme.typography.bodyMedium) + } + if (mode == GpxImportMode.NEW_TOUR) { + OutlinedTextField( + value = newTourName, + onValueChange = { newTourName = it }, + label = { Text("Tourname") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, top = 4.dp, bottom = 4.dp) + ) + } + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = canChooseExisting) { mode = GpxImportMode.EXISTING } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = mode == GpxImportMode.EXISTING, + onClick = { mode = GpxImportMode.EXISTING }, + enabled = canChooseExisting + ) + Spacer(Modifier.width(4.dp)) + Text( + text = if (canChooseExisting) "Zu bestehender Tour hinzufügen" + else "Zu bestehender Tour hinzufügen (keine vorhanden)", + style = MaterialTheme.typography.bodyMedium, + color = if (canChooseExisting) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + if (mode == GpxImportMode.EXISTING && canChooseExisting) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, top = 4.dp, bottom = 4.dp) + ) { + existingTours.forEach { tour -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedExisting = tour } + .padding(vertical = 2.dp) + ) { + RadioButton( + selected = selectedExisting == tour, + onClick = { selectedExisting = tour } + ) + Spacer(Modifier.width(4.dp)) + Text(tour, style = MaterialTheme.typography.bodySmall) + } + } + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Abbrechen verändert nichts.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + }, + confirmButton = { + TextButton( + enabled = canConfirm, + onClick = { + when (mode) { + GpxImportMode.NEW_TOUR -> onImportAsNewTour(newTourName.trim()) + GpxImportMode.EXISTING -> onAppendToExisting(selectedExisting) + } + } + ) { Text("Importieren") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + ) +} + +private enum class GpxImportMode { NEW_TOUR, EXISTING } + +@Composable +private fun DataSafetySection(label: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + content() + } +} + +@Composable +private fun DataSafetyAction( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +// --------------------------------------------------------------------------- +// Backup-Dialog (Auswahl der zu sichernden Sektionen) +// --------------------------------------------------------------------------- + +@Composable +private fun BackupDialog( + optTours: Boolean, + optDrafts: Boolean, + optMedia: Boolean, + optSettings: Boolean, + onToggleTours: (Boolean) -> Unit, + onToggleDrafts: (Boolean) -> Unit, + onToggleMedia: (Boolean) -> Unit, + onToggleSettings: (Boolean) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val anySelected = optTours || optDrafts || optMedia || optSettings + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Backup erstellen") }, + text = { + Column { + Text( + text = "Wählen Sie aus, welche Daten in das ZIP-Backup aufgenommen werden:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(8.dp)) + BackupOptionRow("Touren und Wegpunkte", optTours, onToggleTours) + BackupOptionRow("Track-Entwürfe", optDrafts, onToggleDrafts) + BackupOptionRow("Medien/Atmo", optMedia, onToggleMedia) + BackupOptionRow("Einstellungen", optSettings, onToggleSettings) + Spacer(Modifier.height(8.dp)) + Text( + text = "Hinweis: Mediendateien (Audios, Atmo) werden mitkopiert, wenn sie noch zugänglich sind. Nicht erreichbare Dateien werden in einer Liste vermerkt.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = anySelected) { + Text("Speicherort wählen") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + ) +} + +@Composable +private fun BackupOptionRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +// --------------------------------------------------------------------------- +// Backup-Import-Dialog (Bestätigung mit Sektionsauswahl) +// --------------------------------------------------------------------------- + +@Composable +private fun BackupImportDialog( + preview: WaypointViewModel.BackupPreview, + optTours: Boolean, + optDrafts: Boolean, + optMedia: Boolean, + optSettings: Boolean, + availTours: Boolean, + availDrafts: Boolean, + availMedia: Boolean, + availSettings: Boolean, + onToggleTours: (Boolean) -> Unit, + onToggleDrafts: (Boolean) -> Unit, + onToggleMedia: (Boolean) -> Unit, + onToggleSettings: (Boolean) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val anySelected = (optTours && availTours) || (optDrafts && availDrafts) || + (optMedia && availMedia) || (optSettings && availSettings) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Backup importieren") }, + text = { + Column { + Text( + text = "Backup-Datei prüfen und Sektionen auswählen, die zusammengeführt werden sollen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f) + ) + Spacer(Modifier.height(8.dp)) + if (preview.createdAt != null) { + Text( + text = "Erstellt: ${preview.createdAt}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + if (!preview.appVersionName.isNullOrBlank()) { + Text( + text = "App-Version: ${preview.appVersionName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + Spacer(Modifier.height(8.dp)) + ImportSectionRow("Touren und Wegpunkte", availTours, optTours, onToggleTours) + ImportSectionRow("Track-Entwürfe", availDrafts, optDrafts, onToggleDrafts) + ImportSectionRow("Medien/Atmo", availMedia, optMedia, onToggleMedia) + ImportSectionRow("Einstellungen", availSettings, optSettings, onToggleSettings) + Spacer(Modifier.height(8.dp)) + Text( + text = "Strategie: Zusammenführen. Bestehende Daten werden NICHT überschrieben. " + + "Touren mit gleichem Namen werden mit Suffix „(Import)\" angelegt; " + + "Wegpunkt-IDs werden bei Kollision neu vergeben. " + + "Mediendateien werden in den App-internen Speicher entpackt; " + + "ursprüngliche `content://`-URIs sind nach Geräte-/App-Wechsel ggf. nicht mehr abspielbar.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = anySelected) { + Text("Importieren") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + ) +} + +@Composable +private fun ImportSectionRow( + label: String, + available: Boolean, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked && available, + onCheckedChange = if (available) onCheckedChange else null, + enabled = available + ) + Spacer(Modifier.width(4.dp)) + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = if (available) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + if (!available) { + Text( + text = "(im Backup nicht enthalten)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } } // ----------------------------------------------------------------------- @@ -727,15 +2535,22 @@ private fun TourCounterCard( val onceCount = filteredWaypoints.count { it.playbackMode == PlaybackMode.ONCE } val everyEntryCount = filteredWaypoints.count { it.playbackMode == PlaybackMode.EVERY_ENTRY } - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f), - tonalElevation = 1.dp + // v2.5.1 — Card-Optik in der GPS2Audio-Farbwelt: tertiaryContainer + // (grün/aktiv) als sanfter Akzent, abgerundete Ecken, leichte Erhebung. + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( @@ -805,19 +2620,26 @@ private fun BegleitmusikMiniPlayer( val hasSource = when { !settings.enabled -> false settings.sourceType == MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty() - settings.sourceType == MusicSourceType.STREAM_URL -> !settings.streamUrl.isNullOrBlank() + settings.sourceType == MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty() else -> false } val isPlaying = playbackState.isPlaying val hasContent = playbackState.hasContent - Surface( - modifier = Modifier.fillMaxWidth(), - color = if (settings.enabled) - MaterialTheme.colorScheme.secondaryContainer - else - MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = 2.dp + // v2.5.1 — Atmo-Player als Card: weichere Ecken, leichte Erhebung, + // gleiche Farbwelt wie die HomeScreen-Atmo-Card. + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = if (settings.enabled) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -1166,14 +2988,26 @@ private fun ManualPlayerBar( onPlayPause: () -> Unit, onNext: () -> Unit, /** true if the player is currently in single-item mode (locks Prev/Next) */ - isSingleMode: Boolean = false + isSingleMode: Boolean = false, + /** + * v2.5.14 — Vorformatierte Restzeit (z. B. "Verbleibend 1:23") oder null, + * wenn keine Anzeige sinnvoll ist (Stop-Zustand, unbekannte Dauer). + */ + remainingLabel: String? = null ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = 4.dp + // v2.5.1 — Manueller Player als abgerundete Card mit ruhiger + // surfaceVariant-Fläche (statt flacher Surface mit Elevation). + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { // Label: mode indicator + currently playing waypoint name or disabled hint val labelText = when { !hasPlayable -> stringResource(R.string.manual_no_audio) @@ -1208,7 +3042,22 @@ private fun ManualPlayerBar( ) } - // Prev/Next are disabled in single mode (single plays only 1 file) + // v2.5.14 — Kompakte Restzeit-Anzeige unterhalb des aktuellen Titels. + if (remainingLabel != null && hasPlayable) { + Text( + text = remainingLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) + } + + // v2.5.14: In der flachen Playable-Sequence darf jetzt auch im + // "Single-Mode der Tour-Sequence" (sequenzielles Multi-Clip-Play) + // mit Vor/Zurück durch die Clips navigiert werden. Nur das + // *echte* Single-Clip-Mode (Clip aus Edit-Dialog) sperrt Skip. val canSkip = hasPlayable && !isSingleMode // Control row @@ -1326,6 +3175,79 @@ private fun NewTourDialog( ) } +// ----------------------------------------------------------------------- +// v2.5.15 — Tour duplizieren / Variante anlegen +// ----------------------------------------------------------------------- + +@Composable +private fun DuplicateTourDialog( + sourceTour: String, + existingTours: List, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + // Default: „ – Variante" – wenn der Name schon existiert, hängen wir + // "(2)", "(3)" usw. an, damit der User nichts anpassen muss. + val defaultName = remember(sourceTour, existingTours) { + val base = if (sourceTour.isBlank()) "Variante" else "$sourceTour – Variante" + if (base !in existingTours) base + else { + var n = 2 + while ("$base ($n)" in existingTours) n++ + "$base ($n)" + } + } + var newName by remember { mutableStateOf(defaultName) } + // 0 = no error, 1 = blank, 2 = duplicate + var nameErrorKind by remember { mutableStateOf(0) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Tour duplizieren") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Kopiert „$sourceTour“ als neue Tour mit allen Wegpunkten, " + + "Clips, Cover und Route. Audiodateien werden referenziert " + + "(nicht physisch kopiert).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + OutlinedTextField( + value = newName, + onValueChange = { newName = it; nameErrorKind = 0 }, + label = { Text(stringResource(R.string.tour_name_label)) }, + isError = nameErrorKind != 0, + supportingText = when (nameErrorKind) { + 1 -> {{ Text(stringResource(R.string.tour_name_empty_error)) }} + 2 -> {{ Text(stringResource(R.string.tour_name_duplicate_error)) }} + else -> null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { + val trimmed = newName.trim() + when { + trimmed.isBlank() -> nameErrorKind = 1 + trimmed in existingTours -> nameErrorKind = 2 + else -> onConfirm(trimmed) + } + }) { + Text("Duplizieren") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + // ----------------------------------------------------------------------- // Tour umbenennen Dialog // ----------------------------------------------------------------------- @@ -1387,18 +3309,103 @@ private fun WaypointCard( /** true if THIS waypoint is currently the active single-play item AND playing (not paused). */ isSinglePlaying: Boolean = false, /** true if THIS waypoint is loaded in single mode (playing OR paused). */ - isSingleLoaded: Boolean = false + isSingleLoaded: Boolean = false, + /** + * v2.5.11 — Tippen auf die Karte (außerhalb der Buttons) öffnet die + * Clip-Detailansicht. Wird im Edit-Modus bewusst NICHT verkabelt, damit + * dort Drag/Edit/Delete dominieren. + */ + onOpenDetail: (() -> Unit)? = null, + /** true when drag-and-drop reorder should be active (no GPS/recording AND edit mode). */ + reorderEnabled: Boolean = false, + /** + * v2.5.8 — true = Bearbeitungsmodus (Drag-Handle, Stift, Löschen, Koordinaten + * und Audiofile-Pfad sichtbar). false = Wiedergabemodus (nur Titel, Aktiv- + * status, kurze Info, Audio-Dauer, Play-Button). + */ + editMode: Boolean = false, + /** v2.5.8 — Tracknummer (1-basiert) für Audio-Track-Look. */ + positionNumber: Int = 1, + /** Position of this card in the filtered tour list. */ + index: Int = 0, + /** Shared drag/drop state for this list. */ + reorderState: DragReorderState? = null ) { - // Highlight the card border when this waypoint is active in single mode + // v2.5.1 — Aktive Karte erhält einen subtilen Primary-Border passend zur + // GPS2Audio-Farbwelt (statt der Default-Outlined-Border in Outline-Grau). val cardBorder = if (isSingleLoaded) - CardDefaults.outlinedCardBorder() + androidx.compose.foundation.BorderStroke( + width = 1.2.dp, + color = MaterialTheme.colorScheme.primary + ) else null + // ---------- Drag-and-Drop Hilfswerte ---------- + val isDragging = reorderState?.draggingIndex == index + // Visuelle Verschiebung für diese Karte: + // * Wird sie gerade gezogen -> aufsummierter dragOffset. + // * Liegt sie zwischen Quell- und Zielindex -> komplette Item-Höhe in + // Gegenrichtung, damit der freie Platz an Zielposition entsteht. + val translationY: Float = if (reorderState == null) { + 0f + } else when { + isDragging -> reorderState.dragOffsetPx + reorderState.shiftDirectionFor(index) > 0 -> reorderState.itemHeightFor(index).toFloat() + reorderState.shiftDirectionFor(index) < 0 -> -reorderState.itemHeightFor(index).toFloat() + else -> 0f + } + val cardZ: Float = if (isDragging) 1f else 0f + val cardElevationDp = when { + isDragging -> 8.dp + isSingleLoaded -> 3.dp + else -> 1.dp + } + Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .zIndex(cardZ) + .graphicsLayer { + this.translationY = translationY + } + .then( + if (reorderState != null) { + Modifier.onGloballyPositioned { coords -> + reorderState.reportHeight(index, coords.size.height) + } + } else Modifier + ) + .then( + // Long-press anywhere on the card als Alternative zum Griff. + if (reorderEnabled && reorderState != null) { + Modifier.pointerInput(reorderEnabled, reorderState, index) { + detectDragGesturesAfterLongPress( + onDragStart = { reorderState.onDragStart(index) }, + onDragEnd = { reorderState.onDragEnd() }, + onDragCancel = { reorderState.onDragCancel() }, + onDrag = { change, dragAmount -> + change.consume() + reorderState.onDrag(dragAmount.y) + } + ) + } + } else Modifier + ) + .then( + // v2.5.11 — Tippen auf die Karte öffnet die Clip-Detailansicht. + // Im Edit-Modus bewusst deaktiviert, damit Drag/Edit/Löschen + // ungestört bleiben (kein versehentliches Öffnen). + if (!editMode && onOpenDetail != null) { + Modifier.clickable(onClick = onOpenDetail) + } else Modifier + ), + // v2.5.1 — Weichere Cornern, damit Wegpunkt-Karten optisch zur + // neuen HomeScreen-Bibliothek passen. + shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( containerColor = when { + isDragging -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.65f) isSingleLoaded -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) waypoint.isActive -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.surfaceVariant @@ -1406,20 +3413,99 @@ private fun WaypointCard( ), border = cardBorder, elevation = CardDefaults.cardElevation( - defaultElevation = if (isSingleLoaded) 3.dp else 1.dp + defaultElevation = cardElevationDp ) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + .padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Switch( - checked = waypoint.isActive, - onCheckedChange = { onToggleActive() }, - modifier = Modifier.padding(end = 8.dp) - ) + // v2.5.8 — Ziehgriff nur im Bearbeitungsmodus sichtbar. Im Wiedergabe- + // modus zeigen wir stattdessen die Tracknummer als ruhigen Marker. + if (editMode) { + val dragHandleCd = stringResource(R.string.drag_handle_cd) + Box( + modifier = Modifier + .size(width = 32.dp, height = 40.dp) + .semantics { contentDescription = dragHandleCd } + .then( + if (reorderEnabled && reorderState != null) { + Modifier.pointerInput(reorderEnabled, reorderState, index) { + detectDragGestures( + onDragStart = { reorderState.onDragStart(index) }, + onDragEnd = { reorderState.onDragEnd() }, + onDragCancel = { reorderState.onDragCancel() }, + onDrag = { change, dragAmount -> + change.consume() + reorderState.onDrag(dragAmount.y) + } + ) + } + } else Modifier + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.DragIndicator, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (reorderEnabled) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f) + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.20f) + ) + } + } else { + Box( + modifier = Modifier.size(width = 32.dp, height = 40.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "%02d".format(Locale.US, positionNumber), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + fontWeight = FontWeight.SemiBold + ) + } + } + // v2.5.5 — Dezentere Wegpunkt-Aktivierung: statt des grossen Switch + // ein kompakter IconToggleButton. Funktion bleibt identisch + // (aktiv/inaktiv pro Waypoint), der aktive Zustand ist über Form + // und Akzentfarbe klar erkennbar, das Touch-Ziel bleibt mit 40dp + // gross genug, und ContentDescription bedient Talkback. + val activeCd = if (waypoint.isActive) + "Wegpunkt aktiv – tippen zum Deaktivieren" + else + "Wegpunkt inaktiv – tippen zum Aktivieren" + Surface( + onClick = onToggleActive, + modifier = Modifier + .padding(horizontal = 2.dp) + .size(width = 36.dp, height = 36.dp) + .semantics { contentDescription = activeCd }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), + color = if (waypoint.isActive) + MaterialTheme.colorScheme.primary.copy(alpha = 0.85f) + else + MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = if (waypoint.isActive) 1.dp else 0.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (waypoint.isActive) + Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = null, + tint = if (waypoint.isActive) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + modifier = Modifier.size(20.dp) + ) + } + } + Spacer(Modifier.width(6.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -1429,49 +3515,68 @@ private fun WaypointCard( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = "%.6f, %.6f · %dm".format( - Locale.US, waypoint.latitude, waypoint.longitude, waypoint.radiusMeters.toInt() - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - if (waypoint.soundName.isNotBlank()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.MusicNote, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = if (isSingleLoaded) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.width(4.dp)) + // v2.5.8 — Im Wiedergabemodus zeigen wir nur eine kurze, ruhige + // Track-Metazeile: Radius + Audio-Hinweis. Volle Koordinaten, + // technische Datei-IDs und detaillierte Abspielregeln tauchen + // nur im Bearbeitungsmodus auf. + if (editMode) { + Text( + text = "%.6f, %.6f · %dm".format( + Locale.US, waypoint.latitude, waypoint.longitude, waypoint.radiusMeters.toInt() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + if (waypoint.soundName.isNotBlank()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.MusicNote, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(4.dp)) + Text( + text = waypoint.soundName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + val ruleSummary = buildPlaybackRuleSummary(waypoint) + if (ruleSummary.isNotBlank()) { Text( - text = waypoint.soundName, + text = ruleSummary, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + maxLines = 2, overflow = TextOverflow.Ellipsis ) } - } - // Abspielregel-Zusammenfassung - val ruleSummary = buildPlaybackRuleSummary(waypoint) - if (ruleSummary.isNotBlank()) { + } else { + // Playback-Zeile: dezent, audio-tracksprachlich. + val parts = mutableListOf() + val clipCount = waypoint.activeClips().size + when { + clipCount > 1 -> parts.add("$clipCount Clips") + clipCount == 1 -> parts.add("Audio") + } + parts.add("${waypoint.radiusMeters.toInt()} m Radius") + if (!waypoint.isActive) parts.add("inaktiv") Text( - text = ruleSummary, + text = parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), - maxLines = 2, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 1, overflow = TextOverflow.Ellipsis ) } } // Per-waypoint single-play button - val hasAudio = waypoint.soundUri.isNotBlank() + val hasAudio = waypoint.activeClips().isNotEmpty() if (hasAudio && onPlaySingle != null) { // Button visible when audio is assigned IconButton( @@ -1507,26 +3612,29 @@ private fun WaypointCard( } } - IconButton( - onClick = onEdit, - modifier = Modifier.size(40.dp) - ) { - Icon( - Icons.Filled.Edit, - contentDescription = stringResource(R.string.edit_waypoint), - modifier = Modifier.size(20.dp) - ) - } - IconButton( - onClick = onDelete, - modifier = Modifier.size(40.dp) - ) { - Icon( - Icons.Filled.Delete, - contentDescription = stringResource(R.string.delete_waypoint), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) + // v2.5.8 — Stift- und Löschen-Icons nur im Bearbeitungsmodus. + if (editMode) { + IconButton( + onClick = onEdit, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(R.string.edit_waypoint), + modifier = Modifier.size(20.dp) + ) + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete_waypoint), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } } } } @@ -1558,3 +3666,205 @@ private fun buildPlaybackRuleSummary(waypoint: Waypoint): String { if (parts.isEmpty()) return "" return "Abspielen: " + parts.joinToString(" · ") } + +// ============================================================================= +// Tour-Reorder Dialog (kompakter Ersatz für die alte "Reihenfolge bearbeiten"-Zeile) +// ============================================================================= + +// v2.5.19 — internal sichtbar, damit HomeScreen die Tour-Sortier-UI ohne +// Code-Duplikation wiederverwenden kann (siehe HomeScreen-Overflow). +@Composable +internal fun TourReorderDialog( + tours: List, + onMove: (Int, Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.menu_tour_reorder)) }, + text = { + Column { + Text( + text = stringResource(R.string.tour_reorder_dialog_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(8.dp)) + tours.forEachIndexed { index, tour -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = tour, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = { onMove(index, index - 1) }, + enabled = index > 0, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Filled.KeyboardArrowUp, + contentDescription = stringResource(R.string.sort_move_up), + modifier = Modifier.size(22.dp), + tint = if (index > 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + IconButton( + onClick = { onMove(index, index + 1) }, + enabled = index < tours.size - 1, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = stringResource(R.string.sort_move_down), + modifier = Modifier.size(22.dp), + tint = if (index < tours.size - 1) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.sort_mode_exit)) + } + } + ) +} + +// ----------------------------------------------------------------------- +// Drag-and-Drop Reorder State (v2.0.2) +// ----------------------------------------------------------------------- +// +// Hält den Zustand einer Drag-and-Drop-Sortieraktion für eine Liste von +// `itemCount` Wegpunkten. Die UI ruft `onDragStart` mit dem Quellindex auf, +// reicht jedes Drag-Delta über `onDrag` weiter und meldet das Ende über +// `onDragEnd`. Während eines Drags wird der gewünschte Zielindex aus +// dragOffsetPx und den gemessenen Item-Höhen abgeleitet (siehe +// `computeTargetIndex`). Erst beim Loslassen wird das ViewModel benachrichtigt +// – das vermeidet Persist-Spam und behält saveAll-Semantik bei. +// +// `shiftDirectionFor(i)` liefert +1, falls Item `i` zur Zielposition hin nach +// unten weichen muss (Ziel liegt unterhalb seiner aktuellen Position), -1 für +// nach oben, sonst 0. Damit entsteht der Lücken-Effekt während des Drags. + +@Stable +internal class DragReorderState( + initialItemCount: Int, + private val onMove: (from: Int, to: Int) -> Unit +) { + var itemCount: Int by mutableStateOf(initialItemCount) + var draggingIndex: Int? by mutableStateOf(null) + private set + var dragOffsetPx: Float by mutableStateOf(0f) + private set + + private val heights = mutableStateMapOf() + + fun reportHeight(index: Int, heightPx: Int) { + if (heightPx > 0) heights[index] = heightPx + } + + fun itemHeightFor(index: Int): Int = + heights[index] ?: heights.values.firstOrNull() ?: 1 + + fun onDragStart(index: Int) { + if (index in 0 until itemCount) { + draggingIndex = index + dragOffsetPx = 0f + } + } + + fun onDrag(deltaY: Float) { + if (draggingIndex != null) { + dragOffsetPx += deltaY + } + } + + fun onDragEnd() { + val from = draggingIndex + if (from != null) { + val to = computeTargetIndex(from) + if (to != from) { + onMove(from, to) + } + } + draggingIndex = null + dragOffsetPx = 0f + } + + fun onDragCancel() { + draggingIndex = null + dragOffsetPx = 0f + } + + /** + * Berechnet den Zielindex anhand der aufsummierten Verschiebung. Es wird + * über die gemessenen Item-Höhen iteriert. Jede vollständig überfahrene + * Nachbar-Höhe verschiebt das Ziel um eins. + */ + fun computeTargetIndex(from: Int): Int { + if (from !in 0 until itemCount) return from + var target = from + var remaining = dragOffsetPx + if (remaining > 0f) { + // Nach unten: prüfe nachfolgende Nachbarn der Reihe nach. + while (target < itemCount - 1) { + val nextHeight = (heights[target + 1] ?: heights[from] ?: 0).toFloat() + if (nextHeight <= 0f) break + if (remaining > nextHeight / 2f) { + remaining -= nextHeight + target += 1 + } else break + } + } else if (remaining < 0f) { + // Nach oben. + while (target > 0) { + val prevHeight = (heights[target - 1] ?: heights[from] ?: 0).toFloat() + if (prevHeight <= 0f) break + if (abs(remaining) > prevHeight / 2f) { + remaining += prevHeight + target -= 1 + } else break + } + } + return target + } + + /** + * +1 wenn Item `index` während des aktuellen Drags nach unten weichen muss, + * -1 für nach oben, 0 wenn unverändert. + */ + fun shiftDirectionFor(index: Int): Int { + val from = draggingIndex ?: return 0 + if (index == from) return 0 + val to = computeTargetIndex(from) + if (to == from) return 0 + return when { + from < to && index in (from + 1)..to -> -1 + from > to && index in to until from -> +1 + else -> 0 + } + } +} + +@Composable +internal fun rememberDragReorderState( + itemCount: Int, + onMove: (from: Int, to: Int) -> Unit +): DragReorderState { + val state = remember { DragReorderState(itemCount, onMove) } + // Hält den Item-Count aktuell, falls Wegpunkte hinzugefügt/gelöscht werden. + state.itemCount = itemCount + return state +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/AppVersion.kt b/app/src/main/kotlin/de/waypointaudio/util/AppVersion.kt new file mode 100644 index 0000000..1a0d64f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/AppVersion.kt @@ -0,0 +1,43 @@ +package de.waypointaudio.util + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build + +/** + * Liefert die aktuelle App-Version zur Laufzeit aus dem PackageManager. + * Damit muss die Anzeige bei Versions-Updates nicht mehr manuell gepflegt werden. + */ +data class AppVersionInfo( + val versionName: String, + val versionCode: Long +) { + /** Anzeige-Format, z. B. "1.5.2-version-dialog (17)". */ + val display: String get() = "$versionName ($versionCode)" +} + +object AppVersion { + fun get(context: Context): AppVersionInfo { + val pm = context.packageManager + val pkg = context.packageName + val info: PackageInfo = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(pkg, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(pkg, 0) + } + } catch (_: PackageManager.NameNotFoundException) { + return AppVersionInfo(versionName = "?", versionCode = 0L) + } + val name = info.versionName ?: "?" + val code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode + } else { + @Suppress("DEPRECATION") + info.versionCode.toLong() + } + return AppVersionInfo(versionName = name, versionCode = code) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/BackgroundReliability.kt b/app/src/main/kotlin/de/waypointaudio/util/BackgroundReliability.kt new file mode 100644 index 0000000..2564548 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/BackgroundReliability.kt @@ -0,0 +1,151 @@ +package de.waypointaudio.util + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.util.Log + +/** + * v2.5.13 — Hintergrund-Zuverlässigkeit / Xiaomi-Hilfe. + * + * Auf Xiaomi/Redmi/Poco mit MIUI/HyperOS kann das System Apps zusätzlich + * zu den Standard-Akkuoptimierungen aggressiv beenden (Autostart-Block, + * fehlendes Recents-Lock, Battery-Saver-Restriktionen). Foreground-Service + * + WakeLock allein reichen dort nicht aus. Diese Utility bündelt die + * relevanten Abfragen und Intents, damit die UI dem Nutzer gezielt + * helfen kann, die richtigen Schalter zu finden — ohne dass die App + * selbst Systemeinstellungen ändert. + */ +object BackgroundReliability { + + private const val TAG = "BgReliability" + + /** + * True, wenn die App von Android-Akkuoptimierungen ausgenommen ist. + * Auf API-Leveln, auf denen [PowerManager.isIgnoringBatteryOptimizations] + * nicht verfügbar ist, geben wir true zurück (kein Hinweis nötig). + */ + fun isIgnoringBatteryOptimizations(context: Context): Boolean { + return try { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + ?: return true + pm.isIgnoringBatteryOptimizations(context.packageName) + } catch (e: Throwable) { + Log.w(TAG, "isIgnoringBatteryOptimizations failed: ${e.message}") + true + } + } + + /** + * Xiaomi/Redmi/Poco/HyperOS — case-insensitive Erkennung über + * Manufacturer und Brand. Wir behandeln alle drei Marken gleich, + * weil sie auf derselben MIUI-/HyperOS-Codebasis laufen. + */ + fun isXiaomiFamily(): Boolean { + val m = (Build.MANUFACTURER ?: "").lowercase() + val b = (Build.BRAND ?: "").lowercase() + return listOf("xiaomi", "redmi", "poco").any { tag -> + m.contains(tag) || b.contains(tag) + } + } + + /** + * Versuche, den System-Dialog zur Aufhebung der Akkuoptimierung zu + * öffnen. Fallback: allgemeine Akku-Einstellungen, dann App-Details. + * Crasht nie, gibt false zurück, wenn nichts gestartet werden konnte. + */ + fun openBatteryOptimizationSettings(context: Context): Boolean { + // Direkter Dialog (zeigt den App-spezifischen Whitelist-Toggle). + val direct = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (tryStart(context, direct)) return true + + // Fallback 1: Liste aller Apps mit Akkuoptimierungs-Status. + val list = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (tryStart(context, list)) return true + + // Fallback 2: App-Details (Akku-Sektion ist von dort erreichbar). + return openAppDetailsSettings(context) + } + + /** Öffnet die App-Detail-Einstellungen. */ + fun openAppDetailsSettings(context: Context): Boolean { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return tryStart(context, intent) + } + + /** + * Öffnet die App-Benachrichtigungseinstellungen (API 26+). Fallback: + * App-Details. Auf älteren API-Leveln direkt App-Details. + */ + fun openNotificationSettings(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (tryStart(context, intent)) return true + } + return openAppDetailsSettings(context) + } + + /** + * Versuch, die MIUI-Autostart-Verwaltung zu öffnen. Diese Intents sind + * NICHT Teil der öffentlichen Android-API und können auf jeder HyperOS-/ + * MIUI-Version verschwinden oder anders heißen. Deshalb: + * - Wir probieren mehrere bekannte Komponenten der Reihe nach, + * - fallen still auf die App-Details zurück, wenn nichts greift, + * - und werfen nie eine Exception nach außen. + */ + fun openXiaomiAutostartSettings(context: Context): Boolean { + val candidates = listOf( + ComponentName( + "com.miui.securitycenter", + "com.miui.permcenter.autostart.AutoStartManagementActivity" + ), + ComponentName( + "com.miui.securitycenter", + "com.miui.appmanager.ApplicationsDetailsActivity" + ), + ComponentName( + "com.miui.securitycenter", + "com.miui.powercenter.PowerSettings" + ) + ) + for (cmp in candidates) { + val intent = Intent().apply { + component = cmp + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + if (tryStart(context, intent)) return true + } + return openAppDetailsSettings(context) + } + + private fun tryStart(context: Context, intent: Intent): Boolean { + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } catch (e: SecurityException) { + Log.w(TAG, "startActivity blocked: ${e.message}") + false + } catch (e: Throwable) { + Log.w(TAG, "startActivity unexpected: ${e.message}") + false + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt b/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt new file mode 100644 index 0000000..26b29bf --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt @@ -0,0 +1,518 @@ +package de.waypointaudio.util + +import android.content.Context +import android.util.Log +import de.waypointaudio.data.PoiCategory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import kotlin.math.* + +/** + * Ein Explorer-POI aus Overpass/OpenStreetMap. + * + * v2.5.40 — Erweitertes Datenmodell gegenüber [OverpassPoi]: + * - osmId / osmType für Attribution/Detail-Link + * - rawTags für das POI-Detail-Sheet + * - category aus [PoiCategory] + */ +data class ExplorerPoiResult( + val osmId: Long, + val osmType: String, // "node", "way", "relation" + val displayName: String, + val category: PoiCategory, + val categoryLabel: String, // lesbarer Label wie "Restaurant", "Museum" … + val latitude: Double, + val longitude: Double, + val rawTags: Map = emptyMap(), +) + +/** Einfacher Bounding-Box-Typ für die Overpass-Abfrage. */ +data class LatLngBbox( + val south: Double, + val west: Double, + val north: Double, + val east: Double, +) + +/** + * Explorer-Overpass-Client — v2.5.44 (POI Category Fix). + * + * Rate-Limit-Strategie: + * - KEIN automatisches Polling. + * - Abfragen nur auf explizite Nutzer-Aktion ("POIs laden"). + * - In-Memory-Cache je Kategorie + BBox-Fingerprint. + * - Radius auf ≤ 5 km begrenzt; bei größerem Kartenausschnitt Warnung. + * - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie. + * + * v2.5.44 Bug-Fix: Mapping-Priorität korrigiert. + * - GASTRONOMY, MOBILITY und INFRASTRUCTURE-Tags werden nun ZUERST geprüft, + * bevor historic=* greift. Damit landen Restaurants, Bushaltestellen etc. + * nicht mehr in der Sehenswürdigkeiten-Kategorie. + * - humanizeTag() gibt für alle Kategorien einen sinnvollen Fallback zurück, + * sodass auch namenlose Einträge (z. B. Parkplätze, Bushaltestellen) nicht + * mehr verworfen werden. + * - Default PoiLayerSettings: alle vier Kategorien standardmäßig aktiv + * (vorher nur Sehenswürdigkeiten = Root Cause des gemeldeten Bugs). + * - Erweiterte Overpass-Filter: ferry_terminal, aeroway=aerodrome, + * amenity=ferry_terminal, amenity=shelter, amenity=bench (optional), + * amenity=drinking_water für Infrastruktur. + * - Logcat-Zusammenfassung nach POI-Laden ("POI-Load: X Sehenswürdigkeiten …"). + * + * OSM-Tags je Kategorie: + * + * SIGHTS: + * tourism = attraction | viewpoint | museum | gallery | artwork | zoo | theme_park + * historic = * (jeder Wert) + * amenity = theatre | arts_centre | cinema | place_of_worship + * + * GASTRONOMY: + * amenity = restaurant | cafe | bar | pub | fast_food | ice_cream | + * biergarten | food_court + * + * MOBILITY: + * amenity = parking | bicycle_parking | fuel | charging_station | + * bus_station | ferry_terminal + * highway = bus_stop + * railway = station | halt | tram_stop + * public_transport = station | platform + * aeroway = aerodrome + * + * INFRASTRUCTURE: + * amenity = toilets | hospital | clinic | pharmacy | bank | atm | + * post_office | police | fire_station | library | + * drinking_water | shelter + * + * Attribution: POI-Daten © OpenStreetMap-Mitwirkende, ODbL. + * Overpass API: overpass-api.de + */ +object ExplorerPoiClient { + + private const val TAG = "ExplorerPoiClient" + private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter" + private const val MAX_RADIUS_M = 5000 + + // In-Memory-Cache: key = "kategorie|bboxFingerprint" + private val cache = mutableMapOf>() + + /** Cache leeren — z. B. beim Verlassen des Explorers. */ + fun clearCache() = cache.clear() + + /** + * Lädt POIs für [categories] im Kartenausschnitt [bbox]. + * + * Wenn der Kartenausschnitt zu groß ist (Radius der BBox > [MAX_RADIUS_M]), + * wird auf den Mittelpunkt + MAX_RADIUS_M begrenzt und [onRadiusClamped] aufgerufen. + * + * @return Map von Kategorie → Liste von POIs. + * @throws RuntimeException bei Netzwerkfehler oder HTTP-Fehlercode. + */ + suspend fun loadPois( + context: Context, + bbox: LatLngBbox, + categories: Set, + onRadiusClamped: () -> Unit = {}, + ): Map> = withContext(Dispatchers.IO) { + if (categories.isEmpty()) return@withContext emptyMap() + + val version = AppVersion.get(context).versionName + val userAgent = "GPS2Audio/$version (Android; de.waypointaudio; explorer-poi)" + + // Prüfe ob BBox zu groß ist; wenn ja → Radius-Modus + val centerLat = (bbox.south + bbox.north) / 2.0 + val centerLon = (bbox.west + bbox.east) / 2.0 + val halfDiagM = haversineM(bbox.south, bbox.west, bbox.north, bbox.east) / 2.0 + val effectiveRadius = halfDiagM.coerceAtMost(MAX_RADIUS_M.toDouble()).toInt() + if (halfDiagM > MAX_RADIUS_M) { + withContext(Dispatchers.Main) { onRadiusClamped() } + } + + val result = mutableMapOf>() + + for (cat in categories) { + val cacheKey = "${cat.key}|${centerLat.toBits()}|${centerLon.toBits()}|$effectiveRadius" + val cached = cache[cacheKey] + if (cached != null) { + result[cat] = cached + continue + } + val pois = fetchCategory( + cat, centerLat, centerLon, effectiveRadius, userAgent + ) + cache[cacheKey] = pois + result[cat] = pois + } + + // v2.5.44 — Logcat-Zusammenfassung je Kategorie (intern, nicht störend) + val sightsCount = result[PoiCategory.SIGHTS]?.size ?: 0 + val gastroCount = result[PoiCategory.GASTRONOMY]?.size ?: 0 + val mobilCount = result[PoiCategory.MOBILITY]?.size ?: 0 + val infraCount = result[PoiCategory.INFRASTRUCTURE]?.size ?: 0 + val total = sightsCount + gastroCount + mobilCount + infraCount + Log.i(TAG, "POI-Load: $total gesamt — " + + "${sightsCount} Sehenswürdigkeiten, " + + "${gastroCount} Gastronomie, " + + "${mobilCount} Mobilität, " + + "${infraCount} Infrastruktur") + + result + } + + /** + * Erzeugt eine menschenlesbare Zusammenfassung der geladenen POI-Anzahlen. + * Kann von der UI als Snackbar-Text verwendet werden. + */ + fun summarize(result: Map>): String { + val parts = buildList { + result[PoiCategory.SIGHTS]?.size?.let { if (it > 0) add("$it Sehensw.") } + result[PoiCategory.GASTRONOMY]?.size?.let { if (it > 0) add("$it Gastro") } + result[PoiCategory.MOBILITY]?.size?.let { if (it > 0) add("$it Mobilität") } + result[PoiCategory.INFRASTRUCTURE]?.size?.let{ if (it > 0) add("$it Infra") } + } + val total = result.values.sumOf { it.size } + return if (parts.isEmpty()) "Keine POIs gefunden" + else "POIs: $total geladen (${parts.joinToString(", ")})" + } + + private suspend fun fetchCategory( + category: PoiCategory, + centerLat: Double, + centerLon: Double, + radiusM: Int, + userAgent: String, + ): List { + val overpassQuery = buildOverpassQuery(category, centerLat, centerLon, radiusM) + val encoded = URLEncoder.encode(overpassQuery, "UTF-8") + val url = URL("$OVERPASS_URL?data=$encoded") + val conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + requestMethod = "GET" + setRequestProperty("User-Agent", userAgent) + setRequestProperty("Accept", "application/json") + } + try { + val code = conn.responseCode + if (code !in 200..299) throw RuntimeException("HTTP $code für ${category.key}") + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + return parseOverpassResponse(body, category) + } finally { + conn.disconnect() + } + } + + private fun buildOverpassQuery( + category: PoiCategory, + lat: Double, + lon: Double, + radiusM: Int, + ): String { + val around = "around:$radiusM,$lat,$lon" + val filters = categoryFilters(category) + // Jeder Filter wird als node/way/relation-Union gebaut + val unionParts = filters.joinToString("\n") { (key, value) -> + if (value == "*") { + """ node[$key]($around); + way[$key]($around); + relation[$key]($around);""" + } else { + """ node[$key=$value]($around); + way[$key=$value]($around); + relation[$key=$value]($around);""" + } + } + return """[out:json][timeout:25]; +( +$unionParts +); +out center 100;""".trimIndent() + } + + /** + * OSM-Tag-Filter je Kategorie. Wert "*" bedeutet: jeder Wert. + * + * v2.5.44 Fix: Erweitert um ferry_terminal, tram_stop, aerodrome, + * drinking_water, shelter. Reihenfolge innerhalb SIGHTS unverändert + * (historic bleibt als letzter breitester Filter — gibt aber keine + * Kollision, da jede Kategorie eine eigene Overpass-Anfrage hat). + */ + private fun categoryFilters(cat: PoiCategory): List> = when (cat) { + PoiCategory.SIGHTS -> listOf( + "tourism" to "attraction", + "tourism" to "viewpoint", + "tourism" to "museum", + "tourism" to "gallery", + "tourism" to "artwork", + "tourism" to "zoo", + "tourism" to "theme_park", + "historic" to "*", + "amenity" to "theatre", + "amenity" to "arts_centre", + "amenity" to "cinema", + "amenity" to "place_of_worship", + ) + PoiCategory.GASTRONOMY -> listOf( + "amenity" to "restaurant", + "amenity" to "cafe", + "amenity" to "bar", + "amenity" to "pub", + "amenity" to "fast_food", + "amenity" to "ice_cream", + "amenity" to "biergarten", + "amenity" to "food_court", + ) + PoiCategory.MOBILITY -> listOf( + "amenity" to "parking", + "amenity" to "bicycle_parking", + "amenity" to "fuel", + "amenity" to "charging_station", + "amenity" to "bus_station", + "amenity" to "ferry_terminal", + "highway" to "bus_stop", + "railway" to "station", + "railway" to "halt", + "railway" to "tram_stop", + "public_transport" to "station", + "public_transport" to "platform", + "aeroway" to "aerodrome", + ) + PoiCategory.INFRASTRUCTURE -> listOf( + "amenity" to "toilets", + "amenity" to "hospital", + "amenity" to "clinic", + "amenity" to "pharmacy", + "amenity" to "bank", + "amenity" to "atm", + "amenity" to "post_office", + "amenity" to "police", + "amenity" to "fire_station", + "amenity" to "library", + "amenity" to "drinking_water", + "amenity" to "shelter", + ) + } + + private fun parseOverpassResponse( + json: String, + category: PoiCategory, + ): List { + val root = JSONObject(json) + val elements = root.optJSONArray("elements") ?: return emptyList() + val out = ArrayList(elements.length()) + val seen = HashSet() // Doppelmarker-Schutz via osmId + + for (i in 0 until elements.length()) { + val el = elements.optJSONObject(i) ?: continue + val osmId = el.optLong("id", -1L) + val osmType = el.optString("type", "node") + if (osmId < 0) continue + if (!seen.add(osmId)) continue // Duplikat + + val lat = el.optDouble("lat", Double.NaN).let { + if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) ?: Double.NaN + else it + } + val lon = el.optDouble("lon", Double.NaN).let { + if (it.isNaN()) el.optJSONObject("center")?.optDouble("lon", Double.NaN) ?: Double.NaN + else it + } + if (lat.isNaN() || lon.isNaN()) continue + + val tags = el.optJSONObject("tags") ?: continue + val tagsMap = buildMap { + val keys = tags.keys() + while (keys.hasNext()) { + val k = keys.next() + put(k, tags.optString(k)) + } + } + + // v2.5.44 Fix: Kategorie-Mapping-Priorität korrigiert. + // resolveCategoryLabel() prüft zuerst spezifische Nicht-SIGHTS-Tags + // (amenity=restaurant usw.) BEVOR historic=* greift. Da jede + // Kategorie eine eigene Anfrage abschickt, ist das Mapping hier + // nur für den categoryLabel relevant — die category selbst stammt + // immer aus dem Funktionsparameter (korrekt). + val catLabel = resolveCategoryLabel(tagsMap, category) + + val name = tagsMap["name"]?.takeIf { it.isNotBlank() } + ?: tagsMap["ref"]?.takeIf { it.isNotBlank() } + ?: humanizeTag(tagsMap, category) + ?: continue // namenloser POI ohne Fallback → überspringen + + out.add( + ExplorerPoiResult( + osmId = osmId, + osmType = osmType, + displayName = name, + category = category, + categoryLabel = catLabel, + latitude = lat, + longitude = lon, + rawTags = tagsMap, + ) + ) + } + return out + } + + /** + * Menschenlesbarer Kategorie-Label für den POI (z. B. "Restaurant", "Museum"). + * + * v2.5.44 Fix: Reihenfolge der Prüfungen in SIGHTS korrigiert. + * Gastronomie- und Mobilitäts-spezifische amenity-Werte (restaurant, cafe, + * parking usw.) werden ZUERST auf GASTRONOMY/MOBILITY/INFRASTRUCTURE + * gemappt und kommen nie als Sehenswürdigkeit durch. Da jedoch jede + * Kategorie eine separate Overpass-Anfrage hat, trifft dies in der Praxis + * nur Elemente, die mehrere Tag-Kombinationen tragen (z. B. restaurant + + * tourism=attraction). Für diese gilt: spezifisches Tag gewinnt. + */ + private fun resolveCategoryLabel(tags: Map, cat: PoiCategory): String { + val amenity = tags["amenity"] + val tourism = tags["tourism"] + val historic = tags["historic"] + val railway = tags["railway"] + val highway = tags["highway"] + val publicTransport = tags["public_transport"] + val aeroway = tags["aeroway"] + + // v2.5.44 Fix: Gastronomie/Mobilität/Infrastruktur haben Vorrang, + // auch wenn ein Element gleichzeitig tourism=* oder historic=* trägt. + // Prüfe zuerst die spezifischen Nicht-SIGHTS-Tags. + if (cat != PoiCategory.SIGHTS) { + when (cat) { + PoiCategory.GASTRONOMY -> return when (amenity) { + "restaurant" -> "Restaurant" + "cafe" -> "Café" + "bar" -> "Bar" + "pub" -> "Pub" + "fast_food" -> "Schnellimbiss" + "ice_cream" -> "Eisdiele" + "biergarten" -> "Biergarten" + "food_court" -> "Foodcourt" + else -> "Gastronomie" + } + PoiCategory.MOBILITY -> return when { + amenity == "parking" -> "Parkplatz" + amenity == "bicycle_parking" -> "Fahrradstellplatz" + amenity == "fuel" -> "Tankstelle" + amenity == "charging_station" -> "E-Ladestation" + amenity == "bus_station" -> "Busbahnhof" + amenity == "ferry_terminal" -> "Fährterminal" + highway == "bus_stop" -> "Bushaltestelle" + railway == "station" -> "Bahnhof" + railway == "halt" -> "Haltepunkt" + railway == "tram_stop" -> "Straßenbahnhaltestelle" + publicTransport == "station" -> "ÖPNV-Station" + publicTransport == "platform" -> "Bahnsteig" + aeroway == "aerodrome" -> "Flughafen" + else -> "Mobilität" + } + PoiCategory.INFRASTRUCTURE -> return when (amenity) { + "toilets" -> "Toilette" + "hospital" -> "Krankenhaus" + "clinic" -> "Klinik" + "pharmacy" -> "Apotheke" + "bank" -> "Bank" + "atm" -> "Geldautomat" + "post_office" -> "Postamt" + "police" -> "Polizei" + "fire_station" -> "Feuerwehr" + "library" -> "Bibliothek" + "drinking_water"-> "Trinkwasser" + "shelter" -> "Unterstand" + else -> "Infrastruktur" + } + else -> { /* SIGHTS — weiter unten */ } + } + } + + return when (cat) { + PoiCategory.SIGHTS -> when { + tourism == "attraction" -> "Sehenswürdigkeit" + tourism == "viewpoint" -> "Aussichtspunkt" + tourism == "museum" -> "Museum" + tourism == "gallery" -> "Galerie" + tourism == "artwork" -> "Kunstwerk" + tourism == "zoo" -> "Zoo" + tourism == "theme_park" -> "Freizeitpark" + historic != null -> humanizeHistoric(historic) + amenity == "theatre" -> "Theater" + amenity == "arts_centre" -> "Kulturzentrum" + amenity == "cinema" -> "Kino" + amenity == "place_of_worship" -> "Gotteshaus" + else -> "Sehenswürdigkeit" + } + // Fallbacks für den else-Fall (sollten durch obige when-Blöcke nicht erreicht werden) + PoiCategory.GASTRONOMY -> "Gastronomie" + PoiCategory.MOBILITY -> "Mobilität" + PoiCategory.INFRASTRUCTURE-> "Infrastruktur" + } + } + + /** + * Fallback-Name für namenlose POIs, wenn kein [name]-Tag vorhanden ist. + * + * v2.5.44 Fix: Gibt nun für alle Kategorien einen sinnvollen Fallback + * zurück (nicht mehr nur für SIGHTS+historic und Non-SIGHTS). Damit + * werden z. B. namenlose Bushaltestellen, Parkplätze und Toiletten + * nicht mehr verworfen — sie erhalten den Kategorie-Label als Anzeigename. + * + * Ausnahme: Für SIGHTS ohne tourism/historic/spez. amenity-Tag wäre + * "Sehenswürdigkeit" als Name wenig aussagekräftig — diese werden + * nur dann behalten, wenn historic gesetzt ist (Denkmal, Monument etc.) + * oder ein eindeutiger tourism-Wert vorliegt. + */ + private fun humanizeTag(tags: Map, cat: PoiCategory): String? { + return when (cat) { + PoiCategory.SIGHTS -> { + // Für SIGHTS nur dann Fallback-Name, wenn ein spezifisches Tag vorhanden ist + val label = resolveCategoryLabel(tags, cat) + when { + tags["historic"] != null -> label // Denkmal, Ruine etc. immer behalten + tags["tourism"] != null -> label // tourism=viewpoint/museum etc. + tags["amenity"] in setOf( + "theatre","arts_centre","cinema","place_of_worship" + ) -> label + else -> null // zu generisch → überspringen + } + } + // GASTRONOMY, MOBILITY, INFRASTRUCTURE: immer Fallback-Label zurückgeben + PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat) + PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat) + PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat) + } + } + + private fun humanizeHistoric(value: String): String = when (value) { + "castle" -> "Burg/Schloss" + "monument" -> "Denkmal" + "memorial" -> "Gedenkstätte" + "ruins" -> "Ruine" + "church" -> "Kirche" + "fort" -> "Festung" + "battlefield" -> "Schlachtfeld" + "archaeological_site" -> "Archäologische Stätte" + "manor" -> "Herrenhaus" + "wayside_cross" -> "Wegkreuz" + "wayside_shrine" -> "Wegschrein" + "city_gate" -> "Stadttor" + "locomotive" -> "Lokomotive (hist.)" + "aircraft" -> "Flugzeug (hist.)" + "ship" -> "Schiff (hist.)" + else -> "Historisch" + } + + /** Haversine-Distanz in Metern zwischen zwei LatLon-Punkten. */ + fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val r = 6_371_000.0 + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2) + return 2 * r * asin(sqrt(a)) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/NominatimClient.kt b/app/src/main/kotlin/de/waypointaudio/util/NominatimClient.kt new file mode 100644 index 0000000..3eecd63 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/NominatimClient.kt @@ -0,0 +1,75 @@ +package de.waypointaudio.util + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Ein einzelnes Suchergebnis von Nominatim (OpenStreetMap). + */ +data class NominatimResult( + val displayName: String, + val latitude: Double, + val longitude: Double +) + +/** + * Leichter Nominatim-Client (HttpURLConnection) für die Ortssuche im Editor. + * + * Hinweise: + * - Nutzt die öffentliche Nominatim-Instanz unter https://nominatim.openstreetmap.org/. + * - User-Agent enthält App-Name und Versionsnummer (Nominatim Usage Policy). + * - Limit auf 5 Ergebnisse, format=jsonv2. + * - Antwort wird off-UI-thread (IO-Dispatcher) abgeholt. + */ +object NominatimClient { + + private const val BASE_URL = "https://nominatim.openstreetmap.org/search" + + suspend fun search(context: Context, query: String): List = withContext(Dispatchers.IO) { + val q = query.trim() + if (q.isEmpty()) return@withContext emptyList() + val version = AppVersion.get(context).versionName + val userAgent = "GPS2Audio/$version" + val encoded = URLEncoder.encode(q, "UTF-8") + val url = URL("$BASE_URL?format=jsonv2&limit=5&q=$encoded") + val conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + requestMethod = "GET" + setRequestProperty("User-Agent", userAgent) + setRequestProperty("Accept", "application/json") + setRequestProperty("Accept-Language", "de,en;q=0.7") + } + try { + val code = conn.responseCode + if (code !in 200..299) { + throw RuntimeException("HTTP $code") + } + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + parseResults(body) + } finally { + conn.disconnect() + } + } + + private fun parseResults(json: String): List { + val arr = JSONArray(json) + val out = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + val name = obj.optString("display_name").orEmpty() + val latStr = obj.optString("lat") + val lonStr = obj.optString("lon") + val lat = latStr.toDoubleOrNull() ?: continue + val lon = lonStr.toDoubleOrNull() ?: continue + if (name.isBlank()) continue + out.add(NominatimResult(displayName = name, latitude = lat, longitude = lon)) + } + return out + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/OsrmRoutingClient.kt b/app/src/main/kotlin/de/waypointaudio/util/OsrmRoutingClient.kt new file mode 100644 index 0000000..d62101d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/OsrmRoutingClient.kt @@ -0,0 +1,164 @@ +package de.waypointaudio.util + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** + * Ergebnis einer Routing-Abfrage. + */ +data class OsrmRouteResult( + /** Geometrie der Route als Liste von Lat/Lon-Paaren (lat zuerst). */ + val polyline: List>, + /** Gesamtdistanz in Metern. */ + val distanceMeters: Double, + /** Gesamtdauer in Sekunden. */ + val durationSeconds: Double, +) + +/** + * Leichter OSRM-Client (v2.3.1, v2.4.4 URL-Normalisierung) für konfigurierbare Routing-Endpunkte. + * + * - Erwartet einen OSRM-kompatiblen `/route/v1/driving/...`-Endpunkt. + * - Self-hosted bevorzugt; ein optionaler Demo-Endpunkt (router.project-osrm.org) + * wird vom Nutzer explizit eingetragen — keine harte Default-URL im Code. + * - User-Agent enthält App-Name und Versionsnummer. + * - Liefert Geometrie als `geojson` (lon,lat-Punkte) — wir konvertieren in + * (lat,lon)-Paare für osmdroid-Polylines. + * - Auf fehlerhafte Antworten / 4xx/5xx wird eine RuntimeException geworfen, + * damit die UI „Routenberechnung fehlgeschlagen" anzeigen kann. + * + * v2.4.4: [normalizeBase] akzeptiert sowohl reine Basis-URL als auch eine + * bereits vollständige `/route/v1/driving`-Prefix-URL — beides wird intern + * auf die Basis-URL reduziert, damit `route()` immer genau einen + * `/route/v1/driving/`-Pfad anhängt (keine doppelten Prefixe). + */ +object OsrmRoutingClient { + + /** Demo-OSRM-Server. Nur Test, Rate-Limits — siehe Nutzerhinweis. */ + const val DEMO_ENDPOINT = "https://router.project-osrm.org" + + /** v2.5.2 — Erlaubte Profilschlüssel im OSRM-Pfad. */ + private val KNOWN_PROFILES = listOf("driving", "cycling", "walking", "foot", "bike", "car") + + /** + * Normalisiert eine vom Nutzer eingegebene Endpunkt-URL auf die Basis-URL. + * + * - Trimmen, Whitespace entfernen. + * - Trailing slashes entfernen. + * - Falls die Eingabe bereits einen Pfad `/route/v1/` (mit oder ohne + * weitere Koordinaten) enthält, wird dieser Pfad entfernt, sodass die + * interne Aufrufkonstruktion ihn genau einmal anhängt — keine doppelten + * `/route/v1/...` mehr. Das gilt sowohl für `driving` (v2.4.4-Standard) + * als auch für `cycling`/`walking` (v2.5.2). + * - Liefert leeren String zurück, wenn die Eingabe leer/ungültig ist. + */ + fun normalizeBase(raw: String?): String { + if (raw.isNullOrBlank()) return "" + var s = raw.trim() + // v2.5.2: erkenne `/route/v1/` für alle gängigen Profile. + for (p in KNOWN_PROFILES) { + val marker = "/route/v1/$p" + val idx = s.indexOf(marker) + if (idx >= 0) { + s = s.substring(0, idx) + break + } + } + s = s.trimEnd('/') + return s + } + + /** + * Berechnet eine Route zwischen [waypoints] (mindestens 2 Punkte). + * + * @param endpoint Basis-URL des OSRM-Servers (z. B. `https://router.project-osrm.org`). + * Wird intern via [normalizeBase] normalisiert — der Nutzer + * darf sowohl die reine Basis-URL als auch die vollständige + * `/route/v1/driving`-Variante hinterlegen. + */ + suspend fun route( + context: Context, + endpoint: String, + waypoints: List>, + profile: String = "driving", + ): OsrmRouteResult = withContext(Dispatchers.IO) { + require(waypoints.size >= 2) { "OSRM benötigt mindestens 2 Punkte." } + val base = normalizeBase(endpoint) + require(base.startsWith("http://") || base.startsWith("https://")) { + "Endpunkt muss mit http:// oder https:// beginnen." + } + + // v2.5.2: Profil sicher whitelisten — falls jemand einen unbekannten + // String reicht, fallen wir hart auf `driving` zurück, damit das + // Routing nicht durch Tippfehler dauerhaft scheitert. + val safeProfile = when (profile.trim().lowercase()) { + "cycling", "bike", "bicycle" -> "cycling" + "walking", "foot", "pedestrian" -> "walking" + else -> "driving" + } + + val version = AppVersion.get(context).versionName + val userAgent = "GPS2Audio/$version" + + // OSRM erwartet lon,lat (nicht lat,lon). + val coords = waypoints.joinToString(";") { (lat, lon) -> "$lon,$lat" } + val url = URL("$base/route/v1/$safeProfile/$coords?overview=full&geometries=geojson") + + val conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 12_000 + readTimeout = 25_000 + requestMethod = "GET" + setRequestProperty("User-Agent", userAgent) + setRequestProperty("Accept", "application/json") + } + try { + val code = conn.responseCode + if (code !in 200..299) throw RuntimeException("HTTP $code") + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + parseRoute(body) + } finally { + conn.disconnect() + } + } + + private fun parseRoute(json: String): OsrmRouteResult { + val root = JSONObject(json) + val routes = root.optJSONArray("routes") ?: throw RuntimeException("Keine routes im OSRM-Response") + if (routes.length() == 0) throw RuntimeException("OSRM hat keine Route geliefert") + val first = routes.optJSONObject(0) ?: throw RuntimeException("OSRM-Route ungültig") + val distance = first.optDouble("distance", 0.0) + val duration = first.optDouble("duration", 0.0) + val geom = first.optJSONObject("geometry") ?: throw RuntimeException("Keine Geometrie") + val coords: JSONArray = geom.optJSONArray("coordinates") + ?: throw RuntimeException("Geometrie hat keine coordinates") + val poly = ArrayList>(coords.length()) + for (i in 0 until coords.length()) { + val pair = coords.optJSONArray(i) ?: continue + // GeoJSON: [lon, lat] + val lon = pair.optDouble(0, Double.NaN) + val lat = pair.optDouble(1, Double.NaN) + if (lon.isNaN() || lat.isNaN()) continue + poly.add(lat to lon) + } + return OsrmRouteResult(polyline = poly, distanceMeters = distance, durationSeconds = duration) + } + + /** Formatiert eine Distanz freundlich (z. B. „1,3 km" oder „420 m"). */ + fun formatDistance(meters: Double): String { + return if (meters >= 1000) "%.1f km".format(meters / 1000.0) + else "%d m".format(meters.toInt()) + } + + /** Formatiert eine Dauer freundlich (z. B. „12 min", „1:23 h"). */ + fun formatDuration(seconds: Double): String { + val total = seconds.toInt() + val h = total / 3600 + val m = (total % 3600) / 60 + return if (h > 0) "%d:%02d h".format(h, m) else "%d min".format(m.coerceAtLeast(1)) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/OverpassPoiClient.kt b/app/src/main/kotlin/de/waypointaudio/util/OverpassPoiClient.kt new file mode 100644 index 0000000..f5e9556 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/OverpassPoiClient.kt @@ -0,0 +1,159 @@ +package de.waypointaudio.util + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Ein Online-POI-Treffer aus Overpass/OpenStreetMap. + * + * Bewusst minimal: Name, Kategorie/Tag, Lat/Lon. Die UI berechnet Distanz und + * formatiert weitere Felder. + */ +data class OverpassPoi( + val displayName: String, + val category: String, + val latitude: Double, + val longitude: Double, +) + +/** + * Leichter, nutzergetriggerter Overpass-Client (v2.3.1) für die POI-Suche. + * + * - Nutzt die öffentliche Overpass-API (z. B. overpass-api.de). Bewusst KEIN + * Auto-Polling — nur explizite Suche durch den Nutzer. + * - User-Agent enthält App-Name und Versionsnummer (Nominatim/Overpass + * Usage Policy: contact identifier). + * - Antwort wird off-UI-thread (IO-Dispatcher) abgeholt. + * - Kategorien-Mapping: einfache Stichwortheuristik. Wenn der Nutzer ein + * konkretes OSM-amenity-Tag eintippt (z. B. `bakery`, `museum`, `bus_stop`), + * wird das direkt als Filter genutzt; sonst Volltextsuche im `name`-Tag. + * - Radius standardmäßig 1500 m um das Suchzentrum. + */ +object OverpassPoiClient { + + private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter" + + /** + * Mapping bekannter deutscher Suchbegriffe auf OSM-amenity/shop/tourism-Werte. + * Wenn nichts trifft, wird `null` zurückgegeben und es erfolgt eine + * Volltextsuche über das `name`-Tag. + */ + private val keywordMap = mapOf( + "bäckerei" to "amenity=bakery", + "baeckerei" to "amenity=bakery", + "bakery" to "shop=bakery", + "café" to "amenity=cafe", + "cafe" to "amenity=cafe", + "restaurant" to "amenity=restaurant", + "supermarkt" to "shop=supermarket", + "supermarket" to "shop=supermarket", + "apotheke" to "amenity=pharmacy", + "pharmacy" to "amenity=pharmacy", + "bahnhof" to "railway=station", + "station" to "railway=station", + "bus" to "highway=bus_stop", + "bus_stop" to "highway=bus_stop", + "museum" to "tourism=museum", + "hotel" to "tourism=hotel", + "kirche" to "amenity=place_of_worship", + "church" to "amenity=place_of_worship", + "parkplatz" to "amenity=parking", + "parking" to "amenity=parking", + "toilette" to "amenity=toilets", + "toilet" to "amenity=toilets", + "spielplatz" to "leisure=playground", + "playground" to "leisure=playground", + "viewpoint" to "tourism=viewpoint", + "aussicht" to "tourism=viewpoint", + ) + + /** + * Führt eine POI-Suche um [centerLat]/[centerLon] mit Radius [radiusM] + * durch. Liefert max. 25 Treffer. + * + * @throws RuntimeException bei HTTP-Fehler oder ungültiger JSON-Antwort. + */ + suspend fun search( + context: Context, + query: String, + centerLat: Double, + centerLon: Double, + radiusM: Int = 1500, + ): List = withContext(Dispatchers.IO) { + val q = query.trim() + if (q.isEmpty()) return@withContext emptyList() + val version = AppVersion.get(context).versionName + val userAgent = "GPS2Audio/$version" + + val keyEqVal = keywordMap[q.lowercase()] + val query = + if (keyEqVal != null) { + val (k, v) = keyEqVal.split("=", limit = 2).let { it[0] to it[1] } + """[out:json][timeout:25]; + (node[$k=$v](around:$radiusM,$centerLat,$centerLon); + way[$k=$v](around:$radiusM,$centerLat,$centerLon); + relation[$k=$v](around:$radiusM,$centerLat,$centerLon);); + out center 25;""".trimIndent() + } else { + val safe = q.replace("\"", "") + """[out:json][timeout:25]; + (node[name~"$safe",i](around:$radiusM,$centerLat,$centerLon); + way[name~"$safe",i](around:$radiusM,$centerLat,$centerLon); + relation[name~"$safe",i](around:$radiusM,$centerLat,$centerLon);); + out center 25;""".trimIndent() + } + + val encoded = URLEncoder.encode(query, "UTF-8") + val url = URL("$OVERPASS_URL?data=$encoded") + val conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = 12_000 + readTimeout = 25_000 + requestMethod = "GET" + setRequestProperty("User-Agent", userAgent) + setRequestProperty("Accept", "application/json") + setRequestProperty("Accept-Language", "de,en;q=0.7") + } + try { + val code = conn.responseCode + if (code !in 200..299) throw RuntimeException("HTTP $code") + val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + parseElements(body) + } finally { + conn.disconnect() + } + } + + private fun parseElements(json: String): List { + val root = JSONObject(json) + val elements = root.optJSONArray("elements") ?: return emptyList() + val out = ArrayList(elements.length()) + for (i in 0 until elements.length()) { + val el = elements.optJSONObject(i) ?: continue + val lat = el.optDouble("lat", Double.NaN).let { + if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) else it + } + val lon = el.optDouble("lon", Double.NaN).let { + if (it.isNaN()) el.optJSONObject("center")?.optDouble("lon", Double.NaN) else it + } + if (lat == null || lon == null || lat.isNaN() || lon.isNaN()) continue + val tags = el.optJSONObject("tags") ?: continue + val name = tags.optString("name").orEmpty().ifBlank { + tags.optString("ref").orEmpty() + } + if (name.isBlank()) continue + val category = listOf("amenity", "shop", "tourism", "leisure", "highway", "railway") + .mapNotNull { k -> + val v = tags.optString(k) + if (v.isNotBlank()) "$k=$v" else null + } + .firstOrNull() ?: "poi" + out.add(OverpassPoi(displayName = name, category = category, latitude = lat, longitude = lon)) + } + return out + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/TourCoverImageStore.kt b/app/src/main/kotlin/de/waypointaudio/util/TourCoverImageStore.kt new file mode 100644 index 0000000..87c8775 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/TourCoverImageStore.kt @@ -0,0 +1,164 @@ +package de.waypointaudio.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Rect +import android.net.Uri +import android.util.Log +import java.io.File +import java.io.FileOutputStream + +/** + * v2.5.7 — Persistente Ablage für vom Nutzer gewählte Tour-Cover-Bilder. + * + * Aufgabe: + * - Aus dem Android Photo Picker / ActivityResult kommt ein temporäres + * `content://`-URI mit begrenzter Lebensdauer. Wir kopieren das Bild + * einmalig in die app-interne `filesDir/tour_covers/`. Dadurch: + * • Permissions werden nicht über den App-Lebenszyklus benötigt. + * • Externes Verschieben/Löschen der Quelle bricht das Cover nicht. + * • Der Pfad lässt sich verlustfrei ins Backup/ZIP einpacken. + * + * - Beim Speichern wird das Bild quadratisch zentral zugeschnitten und auf + * eine moderate Kantenlänge (max. 1024 px) runterskaliert. So passt es in + * Hero und Cover-Card („center-crop / fill"), und das Backup bleibt klein. + * + * - Die Dateinamen sind aus dem Tournamen abgeleitet, mit zusätzlichem + * Hash-Suffix, damit gleichnamige Touren aus unterschiedlichen Backups + * nicht kollidieren. + * + * Robustheit: + * - Schlägt das Lesen/Decodieren fehl, wird `null` zurückgegeben — der Aufrufer + * fällt dann auf das deterministische Icon+Palette-Cover zurück. + * - Beim Löschen werden Fehler still ignoriert. + */ +object TourCoverImageStore { + + private const val TAG = "TourCoverImageStore" + private const val DIR_NAME = "tour_covers" + private const val MAX_DIM = 1024 + private const val JPEG_QUALITY = 88 + + /** Verzeichnis, in dem alle Cover-Bilder liegen. Wird bei Bedarf angelegt. */ + fun coversDir(context: Context): File { + val dir = File(context.filesDir, DIR_NAME) + if (!dir.exists()) dir.mkdirs() + return dir + } + + /** + * Kopiert das Bild hinter [sourceUri] in die app-interne Ablage und liefert + * den absoluten Dateipfad zurück. Das Bild wird quadratisch zentral + * zugeschnitten und auf [MAX_DIM] Kantenlänge skaliert. + * + * @return absoluter Pfad oder `null` bei Fehlern. + */ + fun saveFromUri(context: Context, sourceUri: Uri, tourName: String): String? { + return runCatching { + val input = context.contentResolver.openInputStream(sourceUri) + ?: return null + val bytes = input.use { it.readBytes() } + val bmp = decodeSquareCenterCrop(bytes) ?: return null + + val targetDir = coversDir(context) + val baseName = sanitize(tourName) + val unique = Integer.toHexString(System.nanoTime().hashCode()) + val targetFile = File(targetDir, "${baseName}_$unique.jpg") + + FileOutputStream(targetFile).use { fos -> + bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos) + fos.flush() + } + bmp.recycle() + targetFile.absolutePath + }.onFailure { e -> + Log.e(TAG, "saveFromUri fehlgeschlagen für $sourceUri", e) + }.getOrNull() + } + + /** + * Kopiert eine bereits existierende Datei (Pfad oder file://) in die Ablage. + * Wird vom Backup-Import genutzt, wenn ein Cover-Bild aus dem ZIP entpackt + * und unter neuem Pfad gespeichert werden soll. + */ + fun saveFromFile(context: Context, file: File, tourName: String): String? { + return runCatching { + val bytes = file.readBytes() + val bmp = decodeSquareCenterCrop(bytes) ?: return null + val targetDir = coversDir(context) + val baseName = sanitize(tourName) + val unique = Integer.toHexString(System.nanoTime().hashCode()) + val targetFile = File(targetDir, "${baseName}_$unique.jpg") + FileOutputStream(targetFile).use { fos -> + bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos) + } + bmp.recycle() + targetFile.absolutePath + }.onFailure { e -> + Log.e(TAG, "saveFromFile fehlgeschlagen für ${file.absolutePath}", e) + }.getOrNull() + } + + /** Löscht eine Cover-Datei (kein Fehler, wenn sie schon weg ist). */ + fun delete(path: String?) { + if (path.isNullOrBlank()) return + runCatching { + val f = File(path) + if (f.exists()) f.delete() + } + } + + /** + * Decodiert ein Byte-Array zu einem quadratischen Bitmap (center crop) mit + * maximaler Kantenlänge [MAX_DIM]. Liefert `null` bei dekodierungs- oder + * Speicherfehlern. + */ + private fun decodeSquareCenterCrop(bytes: ByteArray): Bitmap? { + // 1) Bounds messen, um inSampleSize zu wählen + val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOpts) + val srcW = boundsOpts.outWidth + val srcH = boundsOpts.outHeight + if (srcW <= 0 || srcH <= 0) return null + + val shortSide = minOf(srcW, srcH) + var sample = 1 + // Verkleinern bis die kürzere Seite knapp über MAX_DIM liegt + while (shortSide / (sample * 2) > MAX_DIM) sample *= 2 + + val decodeOpts = BitmapFactory.Options().apply { + inJustDecodeBounds = false + inSampleSize = sample + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) + ?: return null + + // 2) Center crop quadratisch + val side = minOf(decoded.width, decoded.height) + val x = (decoded.width - side) / 2 + val y = (decoded.height - side) / 2 + val cropped = Bitmap.createBitmap(decoded, x, y, side, side) + if (cropped !== decoded) decoded.recycle() + + // 3) Auf Zielgröße skalieren (falls noch zu groß) + return if (cropped.width > MAX_DIM) { + val scaled = Bitmap.createScaledBitmap(cropped, MAX_DIM, MAX_DIM, true) + if (scaled !== cropped) cropped.recycle() + scaled + } else { + cropped + } + } + + private fun sanitize(s: String): String { + val cleaned = s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_") + .replace(Regex("_+"), "_") + .trim('_', ' ', '.') + .take(40) + return if (cleaned.isBlank()) "tour" else cleaned + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/util/WaypointAudioDuration.kt b/app/src/main/kotlin/de/waypointaudio/util/WaypointAudioDuration.kt new file mode 100644 index 0000000..6658746 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/util/WaypointAudioDuration.kt @@ -0,0 +1,80 @@ +package de.waypointaudio.util + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import java.io.File +import kotlin.math.max + +/** + * Auflösung der Audiodauer eines Wegpunkts (v2.3.0). + * + * Wird im Explorer verwendet, um pro Marker einen Audio-Dauer-Indikator + * (Ring/Badge) zu rendern. Kostenoptimiert: ohne externe Dienste. + * + * - Greift via `MediaMetadataRetriever` zuerst auf die echte Audiolänge zu. + * - Fällt bei nicht lesbaren content://-URIs auf 0 zurück (kein Crash); + * in diesem Fall wird das Audio-Längen-Overlay einfach nicht gezeichnet + * und stattdessen der bestehende Trigger-Radius sichtbar. + * + * Bewusst kein blockierender I/O auf dem Main-Thread: Aufrufer ruft die + * Funktion einmalig in einem `LaunchedEffect` auf und cached das Ergebnis + * in einer einfachen Map. + */ +object WaypointAudioDuration { + + fun resolveDurationMs(context: Context, soundUri: String): Long { + if (soundUri.isBlank()) return 0L + return runCatching { + val mmr = MediaMetadataRetriever() + try { + if (soundUri.startsWith("content://")) { + mmr.setDataSource(context, Uri.parse(soundUri)) + } else if (soundUri.startsWith("file://")) { + val path = Uri.parse(soundUri).path ?: return 0L + if (!File(path).exists()) return 0L + mmr.setDataSource(path) + } else { + val f = File(soundUri) + if (!f.exists()) return 0L + mmr.setDataSource(soundUri) + } + val raw = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + raw?.toLongOrNull() ?: 0L + } finally { + runCatching { mmr.release() } + } + }.getOrElse { 0L } + } + + /** + * Klassifiziert eine Audiolänge in „kurz / mittel / lang" + * für eine konsistente, ruhige Karten-Legende. + */ + fun classify(durationMs: Long): AudioLengthBucket = when { + durationMs <= 0L -> AudioLengthBucket.UNKNOWN + durationMs < 15_000L -> AudioLengthBucket.SHORT + durationMs < 60_000L -> AudioLengthBucket.MEDIUM + else -> AudioLengthBucket.LONG + } + + /** + * Liefert einen 0..1-Wert, der einen visuellen Audiolängen-Ring + * skaliert (gesättigt bei 120s = voller Ring). + */ + fun normalizedFraction(durationMs: Long): Float { + if (durationMs <= 0L) return 0f + val capped = max(0L, durationMs).coerceAtMost(120_000L) + return capped / 120_000f + } + + fun formatShort(durationMs: Long): String { + if (durationMs <= 0L) return "" + val totalSec = (durationMs / 1000L).coerceAtLeast(1L) + val m = totalSec / 60 + val s = totalSec % 60 + return if (m > 0) "%d:%02d".format(m, s) else "0:%02d".format(s) + } +} + +enum class AudioLengthBucket { UNKNOWN, SHORT, MEDIUM, LONG } diff --git a/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt index 93b6422..7ac70a5 100644 --- a/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt +++ b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt @@ -14,16 +14,34 @@ import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.PlayableItem import de.waypointaudio.data.PlaybackMode +import de.waypointaudio.data.PlaybackProgress +import de.waypointaudio.data.PlaylistItem import de.waypointaudio.data.TourAudioSettings import de.waypointaudio.data.TourMusicStore import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.TrackDraft import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.activeClips +import de.waypointaudio.data.effectiveClips +import de.waypointaudio.data.primarySoundUri +import de.waypointaudio.data.toPlayableSequence +import de.waypointaudio.data.withClips +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.firstOrNull +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import de.waypointaudio.io.BackupImportManager import de.waypointaudio.io.ImportExportManager import de.waypointaudio.repository.WaypointRepository import de.waypointaudio.service.BackgroundMusicManager import de.waypointaudio.service.LivePttManager import de.waypointaudio.service.ManualAudioPlayer +import de.waypointaudio.service.WaypointAudioInterruptionCoordinator import de.waypointaudio.service.TrackRecordingManager import de.waypointaudio.service.WaypointLocationService import de.waypointaudio.service.MusicPlaybackState @@ -59,7 +77,18 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application val pttManager = LivePttManager.getInstance(application) /** Dedicated player for manual (UI-driven) audio playback. */ - private val manualPlayer = ManualAudioPlayer() + private val manualPlayer = ManualAudioPlayer().also { mp -> + // Beim PTT-Coordinator registrieren, sodass eine aktive manuelle + // Wegpunkt-Wiedergabe von PTT pausiert/fortgesetzt wird. Die + // "isCurrentlyPlaying"-Lambda stellt sicher, dass nur tatsächlich + // laufende Wiedergaben pausiert werden – nicht bereits pausierte. + WaypointAudioInterruptionCoordinator.register( + source = WaypointAudioInterruptionCoordinator.Source.MANUAL, + isCurrentlyPlaying = { mp.isPlaying }, + pause = { mp.pause() }, + resume = { mp.resume() } + ) + } // ─── Begleitmusik State ─────────────────────────────────────────────────── @@ -153,6 +182,28 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application private val _singlePlayingActive = MutableStateFlow(false) val singlePlayingActive: StateFlow = _singlePlayingActive.asStateFlow() + // ─── v2.5.14: flache Playable-Sequence + Fortschritt ─────────────────────── + + /** + * v2.5.14 — Aktuell abgespielter Eintrag in der flachen Tour-Sequenz, oder + * null, wenn nichts geladen ist. UI verwendet [PlayableItem.clipIndex] / + * [PlayableItem.clipTotal] für die "Clip 2 von 3"-Anzeige im Detail-Sheet. + */ + private val _currentPlayable = MutableStateFlow(null) + val currentPlayable: StateFlow = _currentPlayable.asStateFlow() + + /** + * v2.5.14 — Fortschritt (Position/Dauer) des manuellen Players. + * Wird durch [progressJob] alle [PROGRESS_TICK_MS] ms aktualisiert, solange + * Wiedergabe läuft. Im Pause/Stop-Zustand bleibt der letzte Snapshot + * stehen, sodass die UI nicht auf 0 zurückspringt. + */ + private val _playbackProgress = MutableStateFlow(PlaybackProgress.EMPTY) + val playbackProgress: StateFlow = _playbackProgress.asStateFlow() + + /** Aktiver Polling-Job für den Fortschritt; läuft nur während echter Wiedergabe. */ + private var progressJob: Job? = null + // ─── Tour-weite Abspiel-Vorgaben ────────────────────────────────────────── /** @@ -176,6 +227,50 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application private val _persistedTrack = MutableStateFlow>(emptyList()) val persistedTrack: StateFlow> = _persistedTrack.asStateFlow() + // ─── Track-Drafts (Archiv eigenständiger Aufzeichnungen) ────────────────── + + /** Reaktive Liste aller archivierten Track-Drafts. */ + private val _trackDrafts = MutableStateFlow>(emptyList()) + val trackDrafts: StateFlow> = _trackDrafts.asStateFlow() + + /** + * ID des Drafts, der nach dem letzten Stop gerade automatisch erzeugt wurde. + * UI nutzt das, um direkt in den Editor zu springen. + */ + private val _lastSavedDraftId = MutableStateFlow(null) + val lastSavedDraftId: StateFlow = _lastSavedDraftId.asStateFlow() + + // ─── Audio-Bibliothek (tour-übergreifend) ───────────────────────────────── + + /** + * Aggregierte Audiodatei-Bibliothek, gespeist aus: + * - allen Wegpunkt-Audios (soundUri/soundName) aller Touren + * - allen Begleitmusik-Playlists aller Touren + * + * Dedupliziert nach uriString. Wird im Begleitmusik-Dialog angezeigt, + * damit bereits hinzugefügte Dateien erneut auswählbar sind, ohne sie + * neu über den Datei-Picker auswählen zu müssen. + */ + private val _audioLibrary = MutableStateFlow>(emptyList()) + val audioLibrary: StateFlow> = _audioLibrary.asStateFlow() + + // ─── Tourrouten (v2.4.6) ────────────────────────────────────────────────── + // Persistente Tour→Route-Map. Reaktiv aus dem Repository gespeist. + // Wegpunkte tragen keine Route — die Route gehört zur Tour. + private val _tourRoutes = MutableStateFlow>(emptyMap()) + val tourRoutes: StateFlow> = _tourRoutes.asStateFlow() + + /** Convenience: liefert die gespeicherte Route der aktuellen Tour (oder null). */ + val activeTourRoute: StateFlow + get() = _activeTourRoute + private val _activeTourRoute = MutableStateFlow(null) + + // ─── Tour-Cover (v2.5.5) ────────────────────────────────────────────────── + // Persistente Map tourName → TourCover. Wenn keine Auswahl existiert, + // behält die UI ihre deterministische Standard-Optik (siehe TourHeroSection). + private val _tourCovers = MutableStateFlow>(emptyMap()) + val tourCovers: StateFlow> = _tourCovers.asStateFlow() + // ───────────────────────────────────────────────────────────────────────── init { @@ -220,6 +315,74 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch { loadPersistedTrackForCurrentTour() } + // Track-Drafts beobachten (Archiv) + viewModelScope.launch { + repository.trackDrafts.collect { list -> + // Neueste zuerst + _trackDrafts.value = list.sortedByDescending { it.updatedAt } + } + } + // v2.4.6 — Tourrouten beobachten und aktive Tourroute nachziehen. + viewModelScope.launch { + repository.tourRoutes.collect { map -> + _tourRoutes.value = map + _activeTourRoute.value = map[_selectedTour.value] + } + } + // Beim Tourwechsel die aktive Tourroute aktualisieren. + viewModelScope.launch { + _selectedTour.collect { tour -> + _activeTourRoute.value = _tourRoutes.value[tour] + } + } + // v2.5.5 — Tour-Cover beobachten. + viewModelScope.launch { + repository.tourCovers.collect { map -> + _tourCovers.value = map + } + } + // Audio-Bibliothek aus Wegpunkten + Begleitmusik-Playlists aller Touren ableiten. + // So bleiben bereits hinzugefügte Audiodateien tour-übergreifend auswählbar. + viewModelScope.launch { + combine(repository.waypoints, musicStore.settingsFlow) { wps, musicMap -> + val items = mutableListOf() + // 1) Begleitmusik-Playlists aller Touren + musicMap.values.forEach { settings -> + settings.localPlaylist.forEach { item -> + if (item.uriString.isNotBlank()) items += item + } + } + // 2) Wegpunkt-Audios (soundUri/soundName) – Legacy. + wps.forEach { wp -> + if (wp.soundUri.isNotBlank()) { + items += PlaylistItem( + uriString = wp.soundUri, + displayName = wp.soundName.ifBlank { + wp.soundUri.substringAfterLast('/').substringAfterLast(':') + } + ) + } + // v2.5.12 — alle Clip-URIs ebenfalls in die Bibliothek + // mitnehmen, damit sie tour-übergreifend wiederverwendbar + // sind (z. B. beim erneuten Anlegen eines Wegpunkts). + wp.audioClips.forEach { c -> + if (c.uri.isNotBlank()) { + items += PlaylistItem( + uriString = c.uri, + displayName = c.effectiveTitle.ifBlank { + c.uri.substringAfterLast('/').substringAfterLast(':') + } + ) + } + } + } + items + .distinctBy { it.uriString } + .sortedBy { it.displayName.lowercase() } + }.collect { lib -> + _audioLibrary.value = lib + } + } } // --------------------------------------------------------------------------- @@ -250,29 +413,45 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application // Tour-Verwaltung // --------------------------------------------------------------------------- + /** + * 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. + * Der GPS-Dienst und die Wiedergabe werden dabei NICHT unterbrochen. + */ + fun selectDefaultTour() { + selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) + } + fun selectTour(tourName: String) { // Guard: only select tours that are known, or fall back to first available. val knownTours = _tourList.value val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName else knownTours.first() _selectedTour.value = safeTarget - // Reset manual player when tour changes - manualPlayer.stop() - _manualPlaying.value = false - _manualCurrentName.value = null - _manualIndex.value = -1 - _singlePlayingWaypointId.value = null - _singlePlayingActive.value = false - // Musik für neue Tour laden + // v2.5.30 — Playback-Persistenz beim Tour-Wechsel: + // Laufende Wiedergabe (manuell oder GPS-gesteuert) wird beim blossen + // Navigieren/Wechseln zwischen Touren NICHT gestoppt. + // Stop/Reset passiert nur bei expliziten Nutzeraktionen: + // - Play in einer anderen Tour → stoppt alte, startet neue + // - Stop-Button in der Player-Leiste + // - GPS-Dienst bewusst stoppen + // + // Frühere Zeilen, die hier manualPlayer.stop(), _manualPlaying, _manualIndex, + // _singlePlayingWaypointId, _currentPlayable und stopProgressTicker() gesetzt + // haben, sind entfernt. Sie verhinderten Playback-Persistenz beim Tour-Wechsel. + // + // Musik-Einstellungen der NEUEN Tour laden (keine Wiedergabe stoppen). musicManager.loadTour(safeTarget) viewModelScope.launch { loadMusicSettingsForCurrentTour() loadTourDefaultsForCurrentTour() loadPersistedTrackForCurrentTour() } - // GPS-Track beim Tour-Wechsel stoppen - if (TrackRecordingManager.isRecording.value) stopTrackRecording() - TrackRecordingManager.clearTrack() + // GPS-Track-Aufzeichnung bleibt beim Tour-Wechsel erhalten (v2.5.30). + // Früher wurde stopTrackRecording() + clearTrack() hier aufgerufen. + // Track-Aufzeichnungsstopp nur über expliziten Nutzer-Stop-Button. } fun addTour(tourName: String): Boolean { @@ -314,6 +493,62 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application return true } + /** + * 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 + if (fromIndex == toIndex) return + val item = current.removeAt(fromIndex) + current.add(toIndex, item) + _tourList.value = current + viewModelScope.launch { + runCatching { repository.saveTours(current) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tour-Reihenfolge", e) + } + } + } + + /** + * Verschiebt einen Wegpunkt innerhalb der gefilterten Liste der aktuell ausgewählten Tour. + * Indizes beziehen sich auf die sichtbare Liste (nur Wegpunkte der aktuellen Tour). + * Persistiert die neue Reihenfolge sofort. + */ + fun moveWaypoint(fromIndex: Int, toIndex: Int) { + val tour = _selectedTour.value + val all = _waypoints.value + val tourIndices = all.indices.filter { + all[it].tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour + } + if (fromIndex !in tourIndices.indices || toIndex !in tourIndices.indices) return + if (fromIndex == toIndex) return + + val mutable = all.toMutableList() + val srcGlobal = tourIndices[fromIndex] + val moved = mutable.removeAt(srcGlobal) + val dstGlobal = if (toIndex == tourIndices.size - 1) { + val rebuilt = mutable.indices.filter { + mutable[it].tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour + } + (rebuilt.lastOrNull() ?: -1) + 1 + } else { + val targetGlobal = tourIndices[toIndex] + if (targetGlobal > srcGlobal) targetGlobal - 1 else targetGlobal + } + mutable.add(dstGlobal.coerceIn(0, mutable.size), moved) + _waypoints.value = mutable + viewModelScope.launch { + runCatching { repository.saveAll(mutable) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Wegpunkt-Reihenfolge", e) + } + } + } + fun renameTour(oldName: String, newName: String): Boolean { val trimmed = newName.trim() if (trimmed.isBlank()) return false @@ -327,11 +562,154 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application if (it.tourName == oldName) it.copy(tourName = trimmed) else it } repository.saveAll(updatedWps) + // v2.4.6 — Tourroute-Eintrag mitziehen, damit die Route nicht + // verwaist unter dem alten Namen liegen bleibt. + runCatching { repository.renameTourRouteKey(oldName, trimmed) } + // v2.5.5 — Tour-Cover analog mitziehen. + runCatching { repository.renameTourCoverKey(oldName, trimmed) } if (_selectedTour.value == oldName) _selectedTour.value = trimmed } return true } + // --------------------------------------------------------------------------- + // Tourrouten (v2.4.6) + // --------------------------------------------------------------------------- + + /** + * Speichert/ersetzt die Route der aktuell ausgewählten Tour. Aktualisiert + * den Zeitstempel automatisch. Aufrufer (Routenplaner) übergibt das + * gewünschte [de.waypointaudio.data.TourRoute]-Objekt. + */ + fun saveTourRouteForCurrent(route: de.waypointaudio.data.TourRoute) { + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { + repository.saveTourRoute( + tour, + route.copy(updatedAtMillis = System.currentTimeMillis()) + ) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourroute", e) + _errorMessage.value = "Tourroute konnte nicht gespeichert werden." + } + } + } + + /** Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive). */ + fun saveTourRouteFor(tourName: String, route: de.waypointaudio.data.TourRoute) { + viewModelScope.launch { + runCatching { + repository.saveTourRoute( + tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }, + route.copy(updatedAtMillis = System.currentTimeMillis()) + ) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourroute", e) + _errorMessage.value = "Tourroute konnte nicht gespeichert werden." + } + } + } + + /** Löscht die gespeicherte Route der aktuell ausgewählten Tour. */ + fun deleteTourRouteForCurrent() { + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { repository.deleteTourRoute(tour) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Löschen der Tourroute", e) + } + } + } + + // --------------------------------------------------------------------------- + // Tour-Cover (v2.5.5) + // --------------------------------------------------------------------------- + + /** Setzt das Cover der aktuell ausgewählten Tour. */ + fun setTourCoverForCurrent(cover: de.waypointaudio.data.TourCover) { + val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + // v2.5.7 — Wenn vorher ein eigenes Bild gewählt war und das neue Cover + // *kein* Bild enthält, das alte Bild aufräumen. + val previousImage = _tourCovers.value[tour]?.imageUri + if (!previousImage.isNullOrBlank() && previousImage != cover.imageUri) { + de.waypointaudio.util.TourCoverImageStore.delete(previousImage) + } + viewModelScope.launch { + runCatching { repository.saveTourCover(tour, cover) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern des Tour-Covers", e) + _errorMessage.value = "Tour-Cover konnte nicht gespeichert werden." + } + } + } + + /** Entfernt das Cover der aktuell ausgewählten Tour (zurück zur Standard-Optik). */ + fun clearTourCoverForCurrent() { + val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val previousImage = _tourCovers.value[tour]?.imageUri + if (!previousImage.isNullOrBlank()) { + de.waypointaudio.util.TourCoverImageStore.delete(previousImage) + } + viewModelScope.launch { + runCatching { repository.deleteTourCover(tour) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Löschen des Tour-Covers", e) + } + } + } + + /** + * v2.5.7 — Übernimmt ein vom Nutzer gewähltes Bild als Cover der aktuellen + * Tour. Das Bild wird in die app-interne Ablage kopiert und im + * [de.waypointaudio.data.TourCover] persistiert. Icon/Palette bleiben als + * Fallback erhalten, falls das Bild später verloren geht. + */ + fun setTourCoverImageFromUri(uri: android.net.Uri) { + val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val app = getApplication() + viewModelScope.launch { + val savedPath = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + de.waypointaudio.util.TourCoverImageStore.saveFromUri(app, uri, tour) + } + if (savedPath == null) { + _errorMessage.value = "Bild konnte nicht übernommen werden." + return@launch + } + // Vorheriges Bild (falls vorhanden) aufräumen. + val previousImage = _tourCovers.value[tour]?.imageUri + if (!previousImage.isNullOrBlank() && previousImage != savedPath) { + de.waypointaudio.util.TourCoverImageStore.delete(previousImage) + } + val previous = _tourCovers.value[tour] + ?: de.waypointaudio.data.TourCover() + val updated = previous.copy(imageUri = savedPath) + runCatching { repository.saveTourCover(tour, updated) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Bild-Cover speichern fehlgeschlagen", e) + _errorMessage.value = "Tour-Cover konnte nicht gespeichert werden." + } + } + } + + /** + * v2.5.7 — Entfernt nur das Bild eines vorhandenen Covers (Icon/Palette + * bleiben erhalten). Wird vom Cover-Dialog ausgelöst, wenn der Nutzer + * auf die Icon/Farb-Variante zurückwechselt. + */ + fun clearTourCoverImageForCurrent() { + val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val previous = _tourCovers.value[tour] ?: return + if (previous.imageUri.isNullOrBlank()) return + de.waypointaudio.util.TourCoverImageStore.delete(previous.imageUri) + viewModelScope.launch { + runCatching { repository.saveTourCover(tour, previous.copy(imageUri = null)) } + .onFailure { e -> + android.util.Log.e("WaypointViewModel", "Bild-Cover entfernen fehlgeschlagen", e) + } + } + } + fun deleteTour(tourName: String): Boolean { if (tourName == Waypoint.DEFAULT_TOUR_NAME) return false @@ -367,6 +745,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application repository.saveAll(updatedWps) // Clear GPS track for the deleted tour repository.clearGpsTrack(tourName) + // v2.4.6 — Gespeicherte Tourroute beim Löschen entfernen + runCatching { repository.deleteTourRoute(tourName) } + // v2.5.5 — Cover-Eintrag der Tour entfernen + runCatching { repository.deleteTourCover(tourName) } }.onFailure { e -> android.util.Log.e("WaypointViewModel", "Fehler beim L\u00f6schen der Tour '$tourName'", e) } @@ -383,6 +765,168 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application return true } + // --------------------------------------------------------------------------- + // v2.5.15 — Wegpunkt-/Tour-Varianten + // --------------------------------------------------------------------------- + + /** + * Kopiert einen Wegpunkt (inkl. aller Audio-Clips) in die angegebene + * Zieltour. Vergibt frische IDs für Waypoint und Clips, sodass spätere + * Bearbeitungen unabhängig vom Original bleiben. Audio-URIs werden + * bewusst nicht physisch dupliziert — sie zeigen weiter auf dieselben + * persistenten Medienreferenzen (content://-URIs). Das hält den + * Speicherbedarf minimal und ist Backup-kompatibel, weil der Backup- + * Export Audio bereits anhand der URI sichert. + * + * Der kopierte Wegpunkt wird ans Ende der Zieltour gehängt (über die + * native Reihenfolge in der globalen Wegpunktliste). PlayCount wird + * zurückgesetzt, damit die Variante ihr eigenes Zähl-/Schedule- + * Verhalten bekommt. + * + * @return true, wenn die Kopie angelegt werden konnte. + */ + fun copyWaypointToTour( + sourceWaypointId: String, + targetTourName: String, + newName: String? = null + ): Boolean { + val source = _waypoints.value.firstOrNull { it.id == sourceWaypointId } + ?: return false + val tour = targetTourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME } + + // Neue Audio-Clip-IDs vergeben, damit Bearbeitungen der Kopie nicht + // den Original-Clip mutieren. URI/Dateiname/Reihenfolge bleiben. + val sourceClips = source.effectiveClips() + val copiedClips = sourceClips.map { c -> + c.copy(id = java.util.UUID.randomUUID().toString()) + } + val baseName = (newName ?: source.name).ifBlank { source.name.ifBlank { "Wegpunkt" } } + val copy = source.copy( + id = java.util.UUID.randomUUID().toString(), + name = baseName, + tourName = tour, + // PlayCount neu starten; geplante Zeitfenster bleiben unverändert + // erhalten, damit die Variante 1:1 weiterläuft, wo der User sie + // erwartet. + playCount = 0, + audioClips = emptyList(), // wird via withClips() gesetzt + ).let { base -> + if (copiedClips.isEmpty()) base.copy(soundUri = "", soundName = "") + else base.withClips(copiedClips) + } + + viewModelScope.launch { + repository.upsert(copy) + // Sicherstellen, dass die Zieltour in der Tourliste existiert. + val currentTours = _tourList.value + if (tour !in currentTours) { + runCatching { repository.saveTours(currentTours + tour) } + } + _infoMessage.value = + "Wegpunkt „${copy.name}“ in Tour „$tour“ kopiert." + } + return true + } + + /** + * Dupliziert eine komplette Tour als neue Variante. Vergibt frische + * IDs für die neue Tour (über den neuen Tournamen), alle Waypoints + * und alle Clips. Tour-Cover, Tour-Route, Tour-Defaults und + * Musik-Settings werden mit dem neuen Tournamen mitkopiert, sodass + * die Variante optisch und akustisch identisch startet, danach aber + * unabhängig weiterentwickelt werden kann. Medien-URIs werden — wie + * beim Wegpunkt-Kopieren — referenziert, nicht physisch dupliziert. + * + * @param sourceTourName Quelltour. Wenn nicht vorhanden, schlägt der + * Aufruf still fehl (false). + * @param newTourName Gewünschter Name der Variante. Wird getrimmt; + * leere oder bereits existierende Namen werden + * abgelehnt. + * @param selectAfter Wenn true, wird die neue Tour direkt aktiv + * (Tab-Auswahl), sodass der User die Variante + * sofort sieht. + * @return true bei Erfolg, false bei Validierungsfehler. + */ + fun duplicateTour( + sourceTourName: String, + newTourName: String, + selectAfter: Boolean = true + ): Boolean { + val trimmed = newTourName.trim() + if (trimmed.isBlank()) return false + val currentTours = _tourList.value + if (trimmed in currentTours) return false + val safeSource = sourceTourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val sourceWps = _waypoints.value.filter { + it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == safeSource + } + + // Tourliste sofort optimistisch erweitern, damit Tabs/Selection + // konsistent bleiben. + val updatedTours = currentTours + trimmed + _tourList.value = updatedTours + if (selectAfter) _selectedTour.value = trimmed + + viewModelScope.launch { + runCatching { + repository.saveTours(updatedTours) + // Wegpunkte duplizieren – jede ID neu, Clips ebenfalls neu. + val newWps = sourceWps.map { wp -> + val newClips = wp.effectiveClips().map { c -> + c.copy(id = java.util.UUID.randomUUID().toString()) + } + val base = wp.copy( + id = java.util.UUID.randomUUID().toString(), + tourName = trimmed, + playCount = 0, + audioClips = emptyList(), + ) + if (newClips.isEmpty()) base.copy(soundUri = "", soundName = "") + else base.withClips(newClips) + } + if (newWps.isNotEmpty()) { + repository.saveAll(_waypoints.value + newWps) + } + // Tour-Cover, Tour-Route und Tour-Defaults referenziell mitkopieren. + runCatching { + val covers = repository.tourCovers.firstOrNull() + covers?.get(safeSource)?.let { cover -> + repository.saveTourCover(trimmed, cover) + } + } + runCatching { + val routes = repository.tourRoutes.firstOrNull() + routes?.get(safeSource)?.let { route -> + repository.saveTourRoute( + trimmed, + route.copy(updatedAtMillis = System.currentTimeMillis()) + ) + } + } + runCatching { + val defaults = repository.getTourDefaults(safeSource) + repository.saveTourDefaults(trimmed, defaults) + } + }.onFailure { e -> + android.util.Log.e( + "WaypointViewModel", + "Fehler beim Duplizieren der Tour '$safeSource' → '$trimmed'", e + ) + _errorMessage.value = "Tour konnte nicht dupliziert werden." + } + if (selectAfter) { + runCatching { + musicManager.loadTour(trimmed, force = true) + loadMusicSettingsForCurrentTour() + loadTourDefaultsForCurrentTour() + loadPersistedTrackForCurrentTour() + } + } + _infoMessage.value = "Tour „$trimmed“ als Variante angelegt." + } + return true + } + // --------------------------------------------------------------------------- // Dienst-Steuerung // --------------------------------------------------------------------------- @@ -420,24 +964,28 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application } } - fun importJson(uri: Uri, merge: Boolean = false) { + /** + * v2.5.18 — JSON-Import ist immer additiv. + * + * Bestehende Wegpunkte und Touren werden NIE gelöscht oder ersetzt. Die + * importierten Wegpunkte werden zur bestehenden Liste hinzugefügt. IDs + * werden – falls bereits vorhanden – neu vergeben, sodass keine bestehenden + * Einträge überschrieben werden. + * + * Hinweis: Der frühere zweite Parameter `merge` (Default = false, der + * destruktiv `repository.saveAll(imported)` aufrief) ist entfernt. Die + * sichtbare JSON-Import-UI ist in v2.5.18 ausgeblendet; das Backup-System + * übernimmt diese Rolle. Diese Funktion bleibt als sichere additive + * Fallback-Variante erhalten. + */ + fun importJson(uri: Uri) { viewModelScope.launch { val (imported, error) = ImportExportManager.importJson(getApplication(), uri) if (error != null) { _errorMessage.value = error return@launch } - if (merge) { - val existing = _waypoints.value.associateBy { it.id }.toMutableMap() - imported.forEach { existing[it.id] = it } - repository.saveAll(existing.values.toList()) - } else { - repository.saveAll(imported) - } - val importedTours = imported.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct() - val currentTours = _tourList.value - val merged = (currentTours + importedTours).distinct() - if (merged != currentTours) repository.saveTours(merged) + appendWaypointsAdditively(imported) } } @@ -448,31 +996,427 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application } } - fun importGpx(uri: Uri, merge: Boolean = false) { + // --------------------------------------------------------------------------- + // v2.5.18 — Additiver GPX-Import + // + // Der GPX-Import erstellt entweder eine NEUE Tour oder fügt zu einer + // bestehenden Tour hinzu. In keinem Fall werden bestehende Touren oder + // Wegpunkte gelöscht oder ersetzt. + // --------------------------------------------------------------------------- + + /** + * Tour-Vorschlag aus einem GPX-URI ableiten (Dateiname ohne Endung). + * Fallback: "GPX-Import ". + */ + fun suggestTourNameForGpx(uri: Uri): String { + val raw = runCatching { + val ctx = getApplication() + ctx.contentResolver.query(uri, null, null, null, null)?.use { c -> + val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && c.moveToFirst()) c.getString(idx) else null + } + }.getOrNull() + val base = raw?.substringBeforeLast('.')?.trim().orEmpty() + if (base.isNotBlank()) return base + val ts = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date()) + return "GPX-Import $ts" + } + + /** + * Liest eine GPX-Datei und legt eine neue Tour mit den importierten + * Wegpunkten an. Bestehende Touren und Wegpunkte bleiben unverändert. + * + * Hat eine Tour mit dem gewünschten Namen bereits Einträge, wird der + * Name um eine laufende Nummer ergänzt (z. B. "Foo (2)"), damit nichts + * überschrieben wird. + */ + fun importGpxAsNewTour(uri: Uri, requestedTourName: String) { viewModelScope.launch { val (imported, error) = ImportExportManager.importGpx(getApplication(), uri) if (error != null) { _errorMessage.value = error return@launch } - if (merge) { - val existing = _waypoints.value.associateBy { it.id }.toMutableMap() - imported.forEach { existing[it.id] = it } - repository.saveAll(existing.values.toList()) - } else { - repository.saveAll(imported) + if (imported.isEmpty()) { + _errorMessage.value = "GPX enthält keine Wegpunkte" + return@launch } - val importedTours = imported.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct() + val finalTourName = uniqueTourName( + requestedTourName.trim().ifBlank { suggestTourNameForGpx(uri) } + ) + val existingIds = _waypoints.value.map { it.id }.toHashSet() + val prepared = imported.map { wp -> + wp.copy( + id = if (wp.id.isBlank() || existingIds.contains(wp.id)) + UUID.randomUUID().toString() else wp.id, + tourName = finalTourName + ) + } + val combined = _waypoints.value + prepared + repository.saveAll(combined) val currentTours = _tourList.value - val merged = (currentTours + importedTours).distinct() - if (merged != currentTours) repository.saveTours(merged) + if (!currentTours.contains(finalTourName)) { + repository.saveTours(currentTours + finalTourName) + } + _infoMessage.value = "GPX importiert: ${prepared.size} Wegpunkte in Tour \"$finalTourName\"" } } + /** + * Liest eine GPX-Datei und hängt die importierten Wegpunkte an eine + * bereits bestehende Tour an. Bestehende Wegpunkte dieser Tour bleiben + * vollständig erhalten; die neuen Wegpunkte werden ans Ende der Liste + * angefügt. Andere Touren werden nicht angefasst. + */ + fun importGpxIntoExistingTour(uri: Uri, targetTourName: String) { + viewModelScope.launch { + if (targetTourName.isBlank()) { + _errorMessage.value = "Zieltour ist leer" + return@launch + } + val (imported, error) = ImportExportManager.importGpx(getApplication(), uri) + if (error != null) { + _errorMessage.value = error + return@launch + } + if (imported.isEmpty()) { + _errorMessage.value = "GPX enthält keine Wegpunkte" + return@launch + } + val existingIds = _waypoints.value.map { it.id }.toHashSet() + val prepared = imported.map { wp -> + wp.copy( + id = if (wp.id.isBlank() || existingIds.contains(wp.id)) + UUID.randomUUID().toString() else wp.id, + tourName = targetTourName + ) + } + val combined = _waypoints.value + prepared + repository.saveAll(combined) + val currentTours = _tourList.value + if (!currentTours.contains(targetTourName)) { + repository.saveTours(currentTours + targetTourName) + } + _infoMessage.value = "GPX importiert: ${prepared.size} Wegpunkte an \"$targetTourName\" angehängt" + } + } + + /** + * Interner additiver Append-Pfad für JSON / Legacy. Wahrt alle IDs der + * bestehenden Wegpunkte; importierte Duplikat-IDs werden neu vergeben. + */ + private suspend fun appendWaypointsAdditively(imported: List) { + if (imported.isEmpty()) return + val existingIds = _waypoints.value.map { it.id }.toHashSet() + val prepared = imported.map { wp -> + wp.copy( + id = if (wp.id.isBlank() || existingIds.contains(wp.id)) + UUID.randomUUID().toString() else wp.id, + tourName = wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + ) + } + val combined = _waypoints.value + prepared + repository.saveAll(combined) + val importedTours = prepared.map { it.tourName }.distinct() + val currentTours = _tourList.value + val merged = (currentTours + importedTours).distinct() + if (merged != currentTours) repository.saveTours(merged) + } + + /** Wählt einen Tournamen, der noch nicht in [tourList] vorkommt. */ + private fun uniqueTourName(base: String): String { + val existing = _tourList.value.toHashSet() + if (!existing.contains(base)) return base + var i = 2 + while (existing.contains("$base ($i)")) i++ + return "$base ($i)" + } + fun clearError() { _errorMessage.value = null } + // --------------------------------------------------------------------------- + // Backup-Export (ZIP) + // --------------------------------------------------------------------------- + + /** Letzte Erfolgsmeldung des Backup-Exports/-Imports (German), null wenn keine. */ + private val _infoMessage = MutableStateFlow(null) + val infoMessage: StateFlow = _infoMessage.asStateFlow() + + fun clearInfo() { _infoMessage.value = null } + + /** Setzt eine kurze Erfolgs-/Info-Meldung (Snackbar/Top-Banner). */ + fun postInfo(message: String) { _infoMessage.value = message } + + /** Setzt eine kurze Fehlermeldung (Snackbar/Top-Banner). */ + fun postError(message: String) { _errorMessage.value = message } + + /** + * Manifest-Vorschau eines ausgewählten Backup-ZIPs (ein einmaliger Snapshot). + * Wird nach Auswahl der ZIP-Datei gesetzt und vom Import-Dialog gelesen. + */ + private val _backupPreview = MutableStateFlow(null) + val backupPreview: StateFlow = _backupPreview.asStateFlow() + + /** Hält die zuletzt vom Nutzer bestätigte Quell-URI für den Import. */ + private var pendingBackupUri: Uri? = null + + fun clearBackupPreview() { + _backupPreview.value = null + pendingBackupUri = null + } + + /** + * Beschreibt den Inhalt einer Backup-ZIP-Datei (zur Anzeige im Dialog). + */ + data class BackupPreview( + val createdAt: String?, + val appVersionName: String?, + val sectionToursAndWaypoints: Boolean, + val sectionTrackDrafts: Boolean, + val sectionMediaAndAtmo: Boolean, + val sectionSettings: Boolean + ) + + /** + * Schreibt ein ZIP-Backup an die Ziel-URI mit den ausgewählten Sektionen. + * Liest Tourliste, Wegpunkte, Tour-Vorgaben, GPS-Tracks und Musik-Einstellungen + * aus dem Repository (jeweils ein einmaliger Snapshot). + */ + fun exportBackupZip( + uri: Uri, + toursAndWaypoints: Boolean, + trackDrafts: Boolean, + mediaAndAtmo: Boolean, + settings: Boolean + ) { + 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()) + + 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()) + + val input = de.waypointaudio.io.BackupExportManager.BackupInput( + waypoints = _waypoints.value, + tours = _tourList.value, + tourDefaults = tourDefaultsMap, + gpsTracks = gpsTracks, + musicSettings = musicSettingsMap, + tourRoutes = tourRoutesMap, + tourCovers = tourCoversMap, + 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 { + val sectionList = if (result.sectionsWritten.isEmpty()) "(keine)" + else result.sectionsWritten.joinToString("; ") + val mediaPart = if (mediaAndAtmo) { + " · Medien: ${result.mediaCopied} kopiert, ${result.mediaFailed} nicht erreichbar" + } else "" + _infoMessage.value = "Backup erfolgreich erstellt. Inhalt: $sectionList$mediaPart" + } + } + } + + // --------------------------------------------------------------------------- + // Backup-Import (ZIP) + // --------------------------------------------------------------------------- + + /** + * Liest das Manifest des ausgewählten Backup-ZIPs (off-Main), + * setzt anschließend [_backupPreview] auf die enthaltenen Sektionen. + * Liest KEINE Daten in den DataStore – das passiert erst nach Bestätigung. + */ + fun loadBackupPreview(uri: Uri) { + // Während GPS-Aufzeichnung kein Import zulassen. + if (TrackRecordingManager.isRecording.value) { + _errorMessage.value = + "GPS-Track-Aufzeichnung läuft. Bitte zuerst stoppen, bevor ein Backup importiert wird." + return + } + viewModelScope.launch { + val ctx = getApplication() + val info = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + BackupImportManager.readManifest(ctx, uri) + } + if (info.errorMessage != null) { + _errorMessage.value = info.errorMessage + pendingBackupUri = null + _backupPreview.value = null + return@launch + } + pendingBackupUri = uri + _backupPreview.value = BackupPreview( + createdAt = info.createdAt, + appVersionName = info.appVersionName, + sectionToursAndWaypoints = info.sectionsAvailable.toursAndWaypoints, + sectionTrackDrafts = info.sectionsAvailable.trackDrafts, + sectionMediaAndAtmo = info.sectionsAvailable.mediaAndAtmo, + sectionSettings = info.sectionsAvailable.settings + ) + } + } + + /** + * Importiert das zuvor mit [loadBackupPreview] geladene Backup mit den + * vom Nutzer ausgewählten Sektionen. Merge-Strategie: + * Bestehende Daten werden NICHT überschrieben; neue Touren mit Namenskollision + * werden umbenannt. + */ + fun confirmBackupImport( + toursAndWaypoints: Boolean, + trackDrafts: Boolean, + mediaAndAtmo: Boolean, + settings: Boolean + ) { + val uri = pendingBackupUri ?: run { + _errorMessage.value = "Keine Backup-Datei ausgewählt." + return + } + if (TrackRecordingManager.isRecording.value) { + _errorMessage.value = + "GPS-Track-Aufzeichnung läuft. Bitte zuerst stoppen, bevor ein Backup importiert wird." + return + } + viewModelScope.launch { + val ctx = getApplication() + val existingTracks = runCatching { repository.gpsTracks.first() } + .getOrDefault(emptyMap()) + val existingDefaults = runCatching { repository.tourDefaults.first() } + .getOrDefault(emptyMap()) + val existingMusic = runCatching { musicStore.settingsFlow.first() } + .getOrDefault(emptyMap()) + val existingTours = _tourList.value + val existingWps = _waypoints.value + val existingTourRoutes = runCatching { repository.tourRoutes.first() } + .getOrDefault(emptyMap()) + val existingTourCovers = runCatching { repository.tourCovers.first() } + .getOrDefault(emptyMap()) + + val (payload, error) = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + BackupImportManager.buildImportPayload( + context = ctx, + sourceUri = uri, + selection = BackupImportManager.ImportSelection( + toursAndWaypoints = toursAndWaypoints, + trackDrafts = trackDrafts, + mediaAndAtmo = mediaAndAtmo, + settings = settings + ), + existingWaypoints = existingWps, + existingTours = existingTours, + existingTourDefaults = existingDefaults, + existingTracks = existingTracks, + existingMusic = existingMusic, + existingTourRoutes = existingTourRoutes, + existingTourCovers = existingTourCovers + ) + } + if (error != null || payload == null) { + _errorMessage.value = error ?: "Backup konnte nicht importiert werden." + clearBackupPreview() + return@launch + } + + // ---- Persistieren ---- + runCatching { + if (toursAndWaypoints) { + repository.saveAll(payload.mergedWaypoints) + if (payload.mergedTours != existingTours) { + repository.saveTours(payload.mergedTours) + } + payload.tourDefaultsToWrite.forEach { (tour, def) -> + repository.saveTourDefaults(tour, def) + } + // v2.4.6 — Tourrouten aus dem Import zusammenführen (kein Überschreiben). + if (payload.tourRoutesToWrite.isNotEmpty()) { + repository.mergeTourRoutes(payload.tourRoutesToWrite) + } + // v2.5.5 — Tour-Cover aus dem Import zusammenführen (kein Überschreiben). + if (payload.tourCoversToWrite.isNotEmpty()) { + repository.mergeTourCovers(payload.tourCoversToWrite) + } + } + if (trackDrafts) { + payload.tracksToWrite.forEach { (tour, points) -> + repository.saveGpsTrack(tour, points) + } + } + if (mediaAndAtmo) { + payload.musicToWrite.forEach { (tour, s) -> + musicStore.save(tour, s) + } + } + // _tourList sofort aktualisieren (combine sorgt für nachgezogene Konsistenz). + if (toursAndWaypoints) { + _tourList.value = payload.mergedTours + _waypoints.value = payload.mergedWaypoints + } + // Optional: zu importierter neuer Tour wechseln, falls vorhanden. + payload.newTourSelection?.let { selectTour(it) } + }.onFailure { e -> + _errorMessage.value = "Fehler beim Anwenden des Backups: ${e.localizedMessage ?: e.javaClass.simpleName}" + clearBackupPreview() + return@launch + } + + // Erfolgsmeldung zusammenstellen + val parts = mutableListOf() + if (toursAndWaypoints) { + parts.add("Touren: ${payload.toursImported} (davon ${payload.toursRenamed} umbenannt)") + parts.add("Wegpunkte: ${payload.waypointsImported}") + if (payload.tourDefaultsImported > 0) + parts.add("Tour-Vorgaben: ${payload.tourDefaultsImported}") + if (payload.tourRoutesImported > 0) + parts.add("Tourrouten: ${payload.tourRoutesImported}") + if (payload.tourCoversImported > 0) + parts.add("Tour-Cover: ${payload.tourCoversImported}") + } + if (trackDrafts) parts.add("Track-Entwürfe: ${payload.tracksImported}") + if (mediaAndAtmo) { + parts.add("Begleitmusik: ${payload.musicSettingsImported}") + parts.add("Mediendateien: ${payload.mediaExtracted}") + } + if (settings && payload.trackMinDistanceMeters != null) { + parts.add("Einstellungen: GPS-Mindestabstand ${payload.trackMinDistanceMeters} m vermerkt (read-only)") + } + val warningText = if (payload.warnings.isEmpty()) "" else + "\n\nHinweise:\n• " + payload.warnings.joinToString("\n• ") + + _infoMessage.value = + "Backup-Import abgeschlossen.\n" + parts.joinToString("\n• ", prefix = "• ") + warningText + clearBackupPreview() + } + } + // --------------------------------------------------------------------------- // GPS-Position für neuen Wegpunkt // --------------------------------------------------------------------------- @@ -561,36 +1505,52 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application /** * Returns the playlist of waypoints eligible for manual playback: - * active waypoints in the selected tour that have a soundUri. + * active waypoints in the selected tour that have at least one active + * audio clip (v2.5.12). + * + * v2.5.14: Diese Funktion bleibt als Convenience-Sicht für UI-Komponenten + * erhalten, die nur die Wegpunkt-Ebene brauchen (z. B. der ManualPlayerBar- + * Counter). Die *eigentliche* Wiedergabe-Sequenz wird flach über + * [PlayableItem] mit [playableSequence] geliefert und enthält pro Wegpunkt + * einen Eintrag *je aktivem Clip*. */ fun manualPlaylist(): List { val tour = _selectedTour.value return _waypoints.value.filter { wp -> wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour && wp.isActive && - wp.soundUri.isNotBlank() + wp.activeClips().isNotEmpty() } } + /** + * v2.5.14 — Flache Wiedergabe-Sequenz der aktuellen Tour. + * + * Pro aktivem Wegpunkt expandiert in seine aktiven Clips. Diese Liste ist + * die *einzige Wahrheit* für Tour-Play, ManualPlayerBar-Skip-Buttons, und + * Vor/Zurück im Clip-Detail-Sheet. Die Liste wird bei jedem Aufruf frisch + * aus dem aktuellen Waypoint-State gebaut — kein veralteter Snapshot. + */ + fun playableSequence(): List = + manualPlaylist().toPlayableSequence() + /** * Toggle play/pause for the global manual player bar (playlist mode). * - If single-item mode is active, it is stopped first and playlist mode takes over. - * - If nothing is selected, starts the first playable waypoint. + * - If nothing is selected, starts the first playable item of the tour. * - If playing → pauses. * - If paused → resumes. */ fun manualPlayPause() { - val ctx = getApplication() - val playlist = manualPlaylist() - if (playlist.isEmpty()) return + val sequence = playableSequence() + if (sequence.isEmpty()) return - // PTT-Sperre: Wenn PTT aktiv ist, keine manuelle Wiedergabe starten if (pttManager.pttActive.value) { _errorMessage.value = "PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt." return } - // If single-item mode is active, stop it and transition to playlist mode + // Beim Wechsel aus Single-Mode in Playlist-Mode: Single sauber abbauen. if (manualPlayer.isSingleMode) { manualPlayer.stop() _singlePlayingWaypointId.value = null @@ -600,96 +1560,169 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application if (manualPlayer.isPlaying) { manualPlayer.pause() _manualPlaying.value = false - // Begleitmusik nach Pause des Wegpunkt-Audios wiederherstellen + stopProgressTicker(keepSnapshot = true) musicManager.afterWaypointAudio() return } - val idx = _manualIndex.value.coerceIn(0, playlist.size - 1) - val wp = playlist[idx] - _manualIndex.value = idx - _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${idx + 1}" } - _manualPlaying.value = true - - // Begleitmusik vor Wegpunkt-Audio vorbereiten - musicManager.beforeWaypointAudio() - manualPlayer.play(ctx, wp.soundUri) { - // Playback completed naturally - _manualPlaying.value = false - musicManager.afterWaypointAudio() - } + // Anker für "wo wir wieder einsteigen": entweder der zuletzt aktive + // Playable, sonst Anfang. + val current = _currentPlayable.value + val seqIdx = current + ?.let { p -> sequence.indexOfFirst { it.compoundId == p.compoundId } } + ?.takeIf { it >= 0 } + ?: _manualIndex.value.let { wpIdx -> + if (wpIdx < 0) 0 + else sequence.indexOfFirst { it.waypointIndex == wpIdx + 1 } + .let { if (it < 0) 0 else it } + } + startPlayableAt(sequence, seqIdx) } - /** Skip to previous playable waypoint and start playback (playlist mode). */ + /** + * v2.5.14 — Skip zum vorherigen Playable: bevorzugt voriger Clip im selben + * Wegpunkt, sonst letzter Clip des vorherigen Wegpunkts. Springt am Anfang + * der Tour an das Ende der Sequenz (Wrap-around, konsistent mit dem + * bisherigen Verhalten). + */ fun manualPrevious() { - val ctx = getApplication() - val playlist = manualPlaylist() - if (playlist.isEmpty()) return - - // Exit single mode before switching to playlist navigation + val sequence = playableSequence() + if (sequence.isEmpty()) return if (manualPlayer.isSingleMode) { manualPlayer.stop() _singlePlayingWaypointId.value = null _singlePlayingActive.value = false } - - val newIdx = if (_manualIndex.value <= 0) playlist.size - 1 - else _manualIndex.value - 1 - _manualIndex.value = newIdx - val wp = playlist[newIdx] - _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${newIdx + 1}" } - _manualPlaying.value = true - - musicManager.beforeWaypointAudio() - manualPlayer.play(ctx, wp.soundUri) { - _manualPlaying.value = false - musicManager.afterWaypointAudio() - } + val curIdx = currentSequenceIndex(sequence) + val newIdx = if (curIdx <= 0) sequence.size - 1 else curIdx - 1 + startPlayableAt(sequence, newIdx) } - /** Skip to next playable waypoint and start playback (playlist mode). */ + /** + * v2.5.14 — Skip zum nächsten Playable: bevorzugt nächster Clip desselben + * Wegpunkts, sonst erster Clip des nächsten Wegpunkts. Am Ende der Sequenz + * wird umgebrochen. + */ fun manualNext() { - val ctx = getApplication() - val playlist = manualPlaylist() - if (playlist.isEmpty()) return - - // Exit single mode before switching to playlist navigation + val sequence = playableSequence() + if (sequence.isEmpty()) return if (manualPlayer.isSingleMode) { manualPlayer.stop() _singlePlayingWaypointId.value = null _singlePlayingActive.value = false } - - val newIdx = if (_manualIndex.value >= playlist.size - 1) 0 - else _manualIndex.value + 1 - _manualIndex.value = newIdx - val wp = playlist[newIdx] - _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${newIdx + 1}" } - _manualPlaying.value = true - - musicManager.beforeWaypointAudio() - manualPlayer.play(ctx, wp.soundUri) { - _manualPlaying.value = false - musicManager.afterWaypointAudio() - } + val curIdx = currentSequenceIndex(sequence) + val newIdx = if (curIdx >= sequence.size - 1) 0 else curIdx + 1 + startPlayableAt(sequence, newIdx) } - /** Play a specific waypoint by its position in the current playlist. */ - fun manualPlayAt(index: Int) { + /** + * v2.5.14 — Bestimmt den Index in [sequence], der zum aktuell geladenen + * Playable passt. Fallback: voriger Wegpunkt-Index → erster Clip dieses + * Wegpunkts; sonst 0. + */ + private fun currentSequenceIndex(sequence: List): Int { + val current = _currentPlayable.value + if (current != null) { + val byCompound = sequence.indexOfFirst { it.compoundId == current.compoundId } + if (byCompound >= 0) return byCompound + val byWaypoint = sequence.indexOfFirst { it.waypointId == current.waypointId } + if (byWaypoint >= 0) return byWaypoint + } + val wpIdx = _manualIndex.value + if (wpIdx >= 0) { + val match = sequence.indexOfFirst { it.waypointIndex == wpIdx + 1 } + if (match >= 0) return match + } + return 0 + } + + /** + * v2.5.14 — Startet die Wiedergabe in der Tour-Sequenz an Position + * [seqIndex]. Spielt anschließend automatisch weiter (Clip→Clip→Wegpunkt→ + * …) bis zum Ende der Sequenz; PTT, gelöschte Wegpunkte und nachträglich + * deaktivierte Clips werden beim Auto-Advance jedes Mal neu evaluiert. + */ + private fun startPlayableAt(sequence: List, seqIndex: Int) { + if (sequence.isEmpty()) return + val safeIdx = seqIndex.coerceIn(0, sequence.size - 1) + val item = sequence[safeIdx] + _manualIndex.value = item.waypointIndex - 1 + _currentPlayable.value = item + _manualPlaying.value = true + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + + musicManager.beforeWaypointAudio() + playFromCurrentPlayable(autoAdvance = true) + } + + /** + * v2.5.14 — Spielt den aktuell in [_currentPlayable] hinterlegten Playable + * über den ManualAudioPlayer ab. + * + * Wenn [autoAdvance] = true, wird beim natürlichen Ende automatisch zum + * nächsten Eintrag der Tour-Sequenz weitergesprungen. Bei false bleibt der + * Player nach Ende des Clips stehen (Single-Verhalten). + */ + private fun playFromCurrentPlayable(autoAdvance: Boolean) { val ctx = getApplication() + val item = _currentPlayable.value ?: return + val title = item.displayTitle + _manualCurrentName.value = title + manualPlayer.setNextNotificationMeta( + title = title, + tour = _selectedTour.value + ) + manualPlayer.play(ctx, item.uri) { + if (!autoAdvance) { + _manualPlaying.value = false + _manualCurrentName.value = null + _currentPlayable.value = null + stopProgressTicker(keepSnapshot = false) + musicManager.afterWaypointAudio() + return@play + } + // PTT-Sperre respektieren: keinen neuen Clip starten. + if (pttManager.pttActive.value) { + _manualPlaying.value = false + stopProgressTicker(keepSnapshot = true) + return@play + } + val nextSeq = playableSequence() + val curIdx = nextSeq.indexOfFirst { it.compoundId == item.compoundId } + val nextIdx = curIdx + 1 + if (curIdx < 0 || nextIdx >= nextSeq.size) { + // Ende der Tour-Sequenz oder Wegpunkt zwischenzeitlich entfernt. + _manualPlaying.value = false + _manualCurrentName.value = null + _currentPlayable.value = null + stopProgressTicker(keepSnapshot = false) + musicManager.afterWaypointAudio() + return@play + } + val nextItem = nextSeq[nextIdx] + _currentPlayable.value = nextItem + _manualIndex.value = nextItem.waypointIndex - 1 + playFromCurrentPlayable(autoAdvance = true) + } + startProgressTicker() + } + + /** + * Play a specific waypoint by its position in the current waypoint-level + * playlist. Spielt die volle Clip-Sequenz dieses Wegpunkts und läuft danach + * (Tour-Play-Verhalten) in die nächsten Wegpunkte weiter. + */ + fun manualPlayAt(index: Int) { val playlist = manualPlaylist() if (index !in playlist.indices) return - - val wp = playlist[index] - _manualIndex.value = index - _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${index + 1}" } - _manualPlaying.value = true - - musicManager.beforeWaypointAudio() - manualPlayer.play(ctx, wp.soundUri) { - _manualPlaying.value = false - musicManager.afterWaypointAudio() - } + val sequence = playableSequence() + if (sequence.isEmpty()) return + val targetWp = playlist[index] + val seqIdx = sequence.indexOfFirst { it.waypointId == targetWp.id } + if (seqIdx < 0) return + startPlayableAt(sequence, seqIdx) } /** Stop manual playback completely (both playlist and single mode). */ @@ -698,86 +1731,175 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application _manualPlaying.value = false _singlePlayingWaypointId.value = null _singlePlayingActive.value = false + _currentPlayable.value = null + _manualCurrentName.value = null + stopProgressTicker(keepSnapshot = false) musicManager.afterWaypointAudio() } // --------------------------------------------------------------------------- - // Per-Waypoint Single-Item Playback + // Per-Waypoint Play (full clip sequence, single waypoint scope) // --------------------------------------------------------------------------- /** - * Play (or pause) a single waypoint file triggered from its card. + * v2.5.14 — "Karten-/Listen-Play": Spielt die **volle aktive Cliplist + * dieses Wegpunkts** als Sequenz ab. Nach dem letzten Clip wird wie im + * Tour-Play in den nächsten Wegpunkt der Sequenz weitergesprungen. * - * Behaviour: - * - If global playlist playback is active, it is stopped first. - * - If another waypoint's single play is active, it is stopped and the new one starts. - * - If the same waypoint is already playing in single mode → pauses. - * - If the same waypoint is paused in single mode → resumes. - * - On natural completion, single-mode state is cleared; no next item is queued. - * - Atmo before/after hooks fire around the single file, just like playlist mode. + * Toggle-Verhalten: wenn dieser Wegpunkt bereits in der Sequenz aktiv + * abspielt, pausiert/resumiert der Aufruf den Player. Andernfalls springt + * die Wiedergabe auf den ersten Clip dieses Wegpunkts. + * + * Diese Methode ersetzt das bisherige reine "Primary-Clip"-Verhalten der + * v2.5.13 — siehe Handoff. */ fun manualPlaySingle(waypoint: Waypoint) { - val ctx = getApplication() - val uri = waypoint.soundUri - if (uri.isBlank()) return + if (pttManager.pttActive.value) { + _errorMessage.value = "PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt." + return + } + val sequence = playableSequence() + val seqIdx = sequence.indexOfFirst { it.waypointId == waypoint.id } + if (seqIdx < 0) return + + // Toggle, wenn dieser Wegpunkt aktuell in der Sequenz spielt + // (egal an welchem Clip). + val current = _currentPlayable.value + val isThisWaypointLoaded = current?.waypointId == waypoint.id && !manualPlayer.isSingleMode + if (isThisWaypointLoaded && manualPlayer.hasTrack) { + if (manualPlayer.isPlaying) { + manualPlayer.pause() + _manualPlaying.value = false + stopProgressTicker(keepSnapshot = true) + musicManager.afterWaypointAudio() + } else { + manualPlayer.resume() + _manualPlaying.value = true + startProgressTicker() + musicManager.beforeWaypointAudio() + } + return + } + startPlayableAt(sequence, seqIdx) + } + + /** + * v2.5.12/14 — Spielt einen *bestimmten* Clip eines Wegpunkts als Single- + * Mode (kein Auto-Advance). Aufruf aus der Clip-Liste im Edit-Dialog. + * + * Verhalten: + * - bricht Tour-Play ab, + * - toggelt Pause/Resume, wenn dieselbe (waypoint, clip)-Kombination + * bereits läuft (ID-Schlüssel = "#"), + * - klärt Status nach natürlichem Ende. + */ + fun manualPlaySingleClip(waypoint: Waypoint, clipUri: String, clipId: String, clipTitle: String) { + val ctx = getApplication() + if (clipUri.isBlank()) return - // PTT-Sperre if (pttManager.pttActive.value) { _errorMessage.value = "PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt." return } - val waypointId = waypoint.id - val displayName = waypoint.name.ifBlank { "Wegpunkt" } + val compoundId = "${waypoint.id}#$clipId" + val wpName = waypoint.name.ifBlank { "Wegpunkt" } + val displayName = if (clipTitle.isBlank()) wpName else "$wpName · $clipTitle" - // If global playlist mode is playing, stop it cleanly + // Tour-Play beenden, falls aktiv. if (_manualPlaying.value && !manualPlayer.isSingleMode) { manualPlayer.stop() _manualPlaying.value = false + _currentPlayable.value = null musicManager.afterWaypointAudio() } - // Determine toggle behaviour for the same waypoint - val sameSingleActive = manualPlayer.isSingleMode && - manualPlayer.singleWaypointId == waypointId + val sameClipActive = manualPlayer.isSingleMode && + manualPlayer.singleWaypointId == compoundId - if (sameSingleActive) { - // Toggle pause/resume via playSingle (it handles the toggle internally) + if (sameClipActive) { if (manualPlayer.isPlaying) { - // Pausing: call playSingle which detects same-waypoint-playing → pauses - manualPlayer.playSingle(ctx, waypointId, uri) { - // This callback is only called on natural completion; not on pause - } + manualPlayer.playSingle(ctx, compoundId, clipUri) { /* pause toggle */ } _singlePlayingActive.value = false + stopProgressTicker(keepSnapshot = true) musicManager.afterWaypointAudio() } else { - // Resuming - manualPlayer.playSingle(ctx, waypointId, uri) { + manualPlayer.playSingle(ctx, compoundId, clipUri) { _singlePlayingActive.value = false _singlePlayingWaypointId.value = null _manualCurrentName.value = null + stopProgressTicker(keepSnapshot = false) musicManager.afterWaypointAudio() } _singlePlayingActive.value = true + startProgressTicker() musicManager.beforeWaypointAudio() } return } - // New waypoint single play — Atmo before hook first musicManager.beforeWaypointAudio() - - _singlePlayingWaypointId.value = waypointId + _singlePlayingWaypointId.value = compoundId _singlePlayingActive.value = true _manualCurrentName.value = displayName - - manualPlayer.playSingle(ctx, waypointId, uri) { - // Natural completion — clear state, no auto-advance + manualPlayer.setNextNotificationMeta( + title = displayName, + tour = _selectedTour.value + ) + manualPlayer.playSingle(ctx, compoundId, clipUri) { _singlePlayingWaypointId.value = null _singlePlayingActive.value = false _manualCurrentName.value = null + stopProgressTicker(keepSnapshot = false) musicManager.afterWaypointAudio() } + startProgressTicker() + } + + // --------------------------------------------------------------------------- + // v2.5.14 — Fortschritts-Polling + // --------------------------------------------------------------------------- + + /** + * Startet einen periodischen Ticker, der [playbackProgress] aktualisiert, + * solange tatsächlich Audio abgespielt wird. Mehrfach-Aufrufe sind sicher + * (idempotent — der vorhandene Job läuft weiter). + */ + private fun startProgressTicker() { + if (progressJob?.isActive == true) return + progressJob = viewModelScope.launch { + while (true) { + val playing = manualPlayer.isPlaying + val pos = manualPlayer.getCurrentPositionMs() + val dur = manualPlayer.getDurationMs() + _playbackProgress.value = PlaybackProgress( + positionMs = pos, + durationMs = dur, + isPlaying = playing, + ) + if (!playing && !manualPlayer.hasTrack) { + // Player wurde freigegeben → Ticker beenden. + break + } + delay(PROGRESS_TICK_MS) + } + } + } + + /** + * Beendet den Fortschritts-Ticker. [keepSnapshot]=false setzt den Stand + * zurück auf [PlaybackProgress.EMPTY], damit die UI nach einem Stop nicht + * eine eingefrorene Restzeit zeigt; [keepSnapshot]=true behält die letzte + * Position (z. B. bei Pause). + */ + private fun stopProgressTicker(keepSnapshot: Boolean) { + progressJob?.cancel() + progressJob = null + if (!keepSnapshot) { + _playbackProgress.value = PlaybackProgress.EMPTY + } else { + _playbackProgress.value = _playbackProgress.value.copy(isPlaying = false) + } } // --------------------------------------------------------------------------- @@ -915,7 +2037,7 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application // Prüfen ob Quelle konfiguriert val sourceOk = when (settings.sourceType) { de.waypointaudio.data.MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty() - de.waypointaudio.data.MusicSourceType.STREAM_URL -> !settings.streamUrl.isNullOrBlank() + de.waypointaudio.data.MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty() } if (!sourceOk) { _musicError.value = "Keine Musikquelle konfiguriert. Bitte zuerst Begleitmusik einrichten." @@ -982,15 +2104,19 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application /** * Stoppt die Aufzeichnung und persistiert den Track. * Der Service übernimmt die Persistierung vor dem Stoppen. + * + * Zusätzlich wird der aufgezeichnete Track als NEUER, eigenständiger + * TrackDraft im Archiv gespeichert (mit eigener UUID). Bestehende Drafts + * werden NIE überschrieben. */ fun stopTrackRecording() { val ctx = getApplication() val track = TrackRecordingManager.currentTrack.value + val tour = _selectedTour.value // Zuerst Manager/Service stoppen TrackRecordingManager.stopRecording(ctx) - // Auch ViewModel-seitig persistieren (als Backup) + // Auch ViewModel-seitig persistieren (als Backup für „aktueller Track") if (track.isNotEmpty()) { - val tour = _selectedTour.value viewModelScope.launch { runCatching { repository.saveGpsTrack(tour, track) @@ -998,10 +2124,159 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application }.onFailure { e -> android.util.Log.e("WaypointViewModel", "Fehler beim Speichern des GPS-Tracks", e) } + // Zusätzlich: als eigenständigen Draft archivieren + runCatching { + val draft = TrackDraft( + name = defaultDraftName(tour), + sourceTourName = tour, + points = track + ) + repository.addTrackDraft(draft) + _lastSavedDraftId.value = draft.id + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Archivieren des Drafts", e) + } } } } + /** Erzeugt einen Standardnamen für einen Draft (Tour + Datum/Uhrzeit). */ + private fun defaultDraftName(tour: String): String { + val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + return "$tour · ${fmt.format(Date())}" + } + + /** Setzt das „letzter Draft"-Flag zurück (UI hat darauf reagiert). */ + fun consumeLastSavedDraftId() { + _lastSavedDraftId.value = null + } + + // --------------------------------------------------------------------------- + // Track-Draft-Verwaltung + // --------------------------------------------------------------------------- + + /** Speichert einen neuen oder geänderten Draft (mit aktualisiertem Timestamp). */ + fun saveDraft(draft: TrackDraft) { + viewModelScope.launch { + runCatching { + repository.upsertTrackDraft(draft.copy(updatedAt = System.currentTimeMillis())) + }.onFailure { e -> + _errorMessage.value = "Fehler beim Speichern des Drafts: ${e.localizedMessage}" + } + } + } + + /** Löscht einen Draft. */ + fun deleteDraft(draftId: String) { + viewModelScope.launch { + runCatching { + repository.deleteTrackDraft(draftId) + }.onFailure { e -> + _errorMessage.value = "Fehler beim Löschen des Drafts: ${e.localizedMessage}" + } + } + } + + /** + * Importiert die gewählten Punkte eines Drafts als Wegpunkte in eine Zieltour. + * + * Wenn [draft.markedIndexes] nicht leer ist: nur diese Punkte werden importiert. + * Andernfalls werden ALLE Punkte importiert. + * + * Der Draft selbst bleibt erhalten und kann weiterhin bearbeitet werden. + * + * @return Anzahl tatsächlich importierter Wegpunkte + */ + fun importDraftAsWaypoints(draft: TrackDraft, targetTour: String): Int { + if (isRecording.value) { + _errorMessage.value = "Während der Aufzeichnung kann nicht importiert werden." + return 0 + } + val target = targetTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val indexes = if (draft.markedIndexes.isNotEmpty()) + draft.markedIndexes.sorted() + else + draft.points.indices.toList() + if (indexes.isEmpty()) return 0 + + val defaults = _tourDefaults.value + val baseName = draft.name.ifBlank { "Draft" } + val newWaypoints = indexes.mapIndexed { i, pIdx -> + val p = draft.points.getOrNull(pIdx) ?: return@mapIndexed null + Waypoint( + name = "$baseName #${i + 1}", + latitude = p.latitude, + longitude = p.longitude, + tourName = target, + playbackMode = defaults.playbackMode, + maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT) + defaults.maxPlayCount else null, + playCount = 0 + ) + }.filterNotNull() + + viewModelScope.launch { + runCatching { + val current = _waypoints.value.toMutableList() + current.addAll(newWaypoints) + repository.saveAll(current) + // Tour ggf. zur Tourliste hinzufügen + val tours = _tourList.value + if (target !in tours) repository.saveTours(tours + target) + }.onFailure { e -> + _errorMessage.value = "Fehler beim Importieren: ${e.localizedMessage}" + } + } + return newWaypoints.size + } + + /** + * Storyboard-MVP (v2.2.3): + * Übernimmt die vom Nutzer im Explorer festgelegte Reihenfolge ausgewählter + * Wegpunkte in eine Zieltour. Die Funktion erstellt für jeden Eintrag eine + * neue Wegpunkt-Kopie (eigene ID) in der Zieltour, sortiert nach der + * Eingabereihenfolge. Bestehende Wegpunkte der Zieltour bleiben erhalten. + * + * Es ist BEWUSST keine Straßenroutenberechnung — eine reale Routing-Engine + * wird hier nicht eingesetzt (keine API, keine Kosten). Das Storyboard + * versteht sich als konzeptionelle Tour-Zusammenstellung. + * + * @return Anzahl tatsächlich angelegter Wegpunkte + */ + fun applyStoryboardOrder(targetTour: String, orderedIds: List): Int { + if (orderedIds.isEmpty()) return 0 + val target = targetTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + val source = _waypoints.value.associateBy { it.id } + val defaults = _tourDefaults.value + val newWaypoints = orderedIds.mapIndexedNotNull { i, id -> + val src = source[id] ?: return@mapIndexedNotNull null + Waypoint( + name = src.name.ifBlank { "Punkt ${i + 1}" } + " #${i + 1}", + latitude = src.latitude, + longitude = src.longitude, + radiusMeters = src.radiusMeters, + tourName = target, + playbackMode = defaults.playbackMode, + maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT) + defaults.maxPlayCount else null, + playCount = 0 + ) + } + if (newWaypoints.isEmpty()) return 0 + viewModelScope.launch { + runCatching { + val current = _waypoints.value.toMutableList() + current.addAll(newWaypoints) + repository.saveAll(current) + val tours = _tourList.value + if (target !in tours) repository.saveTours(tours + target) + }.onFailure { e -> + _errorMessage.value = "Fehler beim Speichern des Storyboards: ${e.localizedMessage}" + } + } + return newWaypoints.size + } + /** Löscht den gespeicherten GPS-Track der aktuellen Tour. */ fun clearTrack() { // Aufzeichnung stoppen falls aktiv @@ -1026,9 +2301,33 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application runCatching { val tracks = repository.gpsTracks.first() _persistedTrack.value = tracks[_selectedTour.value] ?: emptyList() - // Den persistierten Track auch in den Manager setzen (für Anzeige) - if (_persistedTrack.value.isNotEmpty() && TrackRecordingManager.currentTrack.value.isEmpty()) { - TrackRecordingManager.setTrack(_persistedTrack.value) + // v2.5.33 — Phantom-Track-Fix: Wenn keine Aufzeichnung läuft, + // muss der Manager-Track zur aktuellen Tour passen. + // Fehlt der Tour ein persistierter Track (neu erstellte Tour oder + // Tour ohne Aufzeichnung), wird der Manager-Track zurückgesetzt, + // damit kein verwaister Track einer anderen Tour auf der Karte + // als Phantom erscheint. + // v2.5.36 — Phantom-Guard verschärft: Für die neutrale Standardtour + // (DEFAULT_TOUR_NAME, kein aktives Recording) wird der Manager-Track + // NICHT mit dem persistierten Track besetzt. Ohne diesen Guard würde + // currentTrack.isNotEmpty() in visibleTrack feuern, bevor die + // isNeutralStandard-Prüfung greift, und die weiße Phantom-Linie + // würde trotz v2.5.35-Guards weiter erscheinen. + val isDefaultNeutral = _selectedTour.value == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME + && !TrackRecordingManager.isRecording.value + if (!TrackRecordingManager.isRecording.value) { + if (_persistedTrack.value.isNotEmpty() && !isDefaultNeutral) { + // Für echte Touren: persistierten Track in Manager laden, + // aber nur wenn der Manager noch keinen eigenen hat (d. h. kein + // laufendes Stop-after-Save-Szenario). + if (TrackRecordingManager.currentTrack.value.isEmpty()) { + TrackRecordingManager.setTrack(_persistedTrack.value) + } + } else { + // Neue/leere Tour ODER neutrale Standardtour → Manager-Track leeren, + // damit kein Phantom-Track aus einer anderen Tour sichtbar bleibt. + TrackRecordingManager.clearTrack() + } } }.onFailure { e -> android.util.Log.e("WaypointViewModel", "Fehler beim Laden des GPS-Tracks", e) @@ -1037,6 +2336,21 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application override fun onCleared() { super.onCleared() + WaypointAudioInterruptionCoordinator.unregister( + WaypointAudioInterruptionCoordinator.Source.MANUAL + ) + progressJob?.cancel() + progressJob = null manualPlayer.release() } + + companion object { + /** + * v2.5.14 — Polling-Intervall für den Wiedergabe-Fortschritt. + * 500 ms ist Compose-freundlich (zwei Updates pro Sekunde reichen für + * eine sauber laufende "Verbleibend mm:ss"-Anzeige) und schont den + * Akku spürbar gegenüber häufigeren Ticks. + */ + private const val PROGRESS_TICK_MS = 500L + } } diff --git a/app/src/main/res/drawable-nodpi/matrix_contact_qr.png b/app/src/main/res/drawable-nodpi/matrix_contact_qr.png deleted file mode 100644 index 85479e7..0000000 Binary files a/app/src/main/res/drawable-nodpi/matrix_contact_qr.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_splash_logo.xml b/app/src/main/res/drawable/ic_splash_logo.xml new file mode 100644 index 0000000..a0dcd7c --- /dev/null +++ b/app/src/main/res/drawable/ic_splash_logo.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/raw/gps2audio_license_public.pem b/app/src/main/res/raw/gps2audio_license_public.pem new file mode 100644 index 0000000..9b778d1 --- /dev/null +++ b/app/src/main/res/raw/gps2audio_license_public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ544rSBSWPjtB/0erDgpIoZhfgTg +7lk7rPb2trDPkpTGVFL9hMH6saaXiLn8KfRi26CfLufJGOMaX9DWYHo0wQ== +-----END PUBLIC KEY----- diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..89eb23e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0D1B2A + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81a2d00..8d65e2c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,12 @@ GPS-Track wird aufgezeichnet%1$s Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert. + + Begleitmusik (Atmo) + Hält die Begleitmusik im Hintergrund aktiv, wenn die App minimiert ist. + Begleitmusik läuft + GPS2Audio + Wegpunkt hinzufügen Wegpunkt bearbeiten @@ -50,12 +56,58 @@ Karte Kartendaten © OpenStreetMap-Mitwirkende + + © OpenStreetMap-Mitwirkende + Kartendaten © OpenStreetMap-Mitwirkende · ODbL + + + Track-Editor + Track-Editor + Noch keine Aufzeichnungen.\nNimm einen Track auf der Karte auf — er wird hier automatisch archiviert. + Tour: %1$s · %2$s + %1$d Punkte · %2$s + %1$d markiert + Unbenannter Track + %1$d Punkte · %2$d markiert + Track umbenennen + Name + Notiz (optional) + Aufzeichnung löschen + Aufzeichnung löschen? + „%1$s" wird unwiderruflich gelöscht. + Punkt %1$d / %2$d + Als Wegpunkt markieren + Markierung entfernen + Karte lange drücken, um den ausgewählten Punkt zu verschieben. + Tippe einen Streckenpunkt, um ihn als Wegpunkt zu markieren. Lang drücken wählt ihn zum Verschieben oder Löschen aus. + Alle markieren + Markierungen löschen + Während der Aufzeichnung gesperrt — bitte zuerst stoppen. + In Tour übernehmen + Markierte Punkte als Wegpunkte importieren + %1$d von %2$d markierte Punkte werden importiert. Nicht markierte bleiben im Draft. + Keine Wegpunkte markiert — tippe Streckenpunkte, um sie zu markieren. + Zieltour + %1$d übernehmen + Keine Wegpunkte markiert + + + Ort suchen + Ort suchen + Suchbegriff + Suchen + Suche läuft … + Keine Treffer gefunden. + Suche fehlgeschlagen — bitte Internetverbindung prüfen. + Suche: OpenStreetMap/Nominatim + Schließen + Als Wegpunkt übernehmen Koordinaten aus aktuellem GPS-Standort vorausgefüllt. - Marker antippen · Karte lange drücken + Streckenpunkt antippen zum Auswählen (rot) · Karte lange drücken für neuen Punkt Bearbeiten Löschen Unbenannter Wegpunkt @@ -84,28 +136,246 @@ Über diese App Über diese App GPS2Audio - Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden. + GPS2Audio überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Ideal für geführte Touren, Audioguides und ortsbezogene Klangerlebnisse. Version - 1.0 + 1.5.5-tabrow-fix Entwickler Marcel Mayer E-Mail marcel.mayer@nesohub.org + Nutzungsbedingungen + Die private und nicht-kommerzielle Nutzung von GPS2Audio ist frei. Jede kommerzielle Nutzung der App, der Marke „GPS2Audio“, der bereitgestellten Inhalte/Touren sowie der signierten Builds – insbesondere durch Bus-, Reise-, Schifffahrts- oder andere gewerbliche Anbieter – ist ohne ausdrückliche schriftliche Genehmigung des Entwicklers untersagt. Für gewerbliche Nutzung ist ein gesonderter Vertrag erforderlich. Anfragen bitte per E-Mail an den Entwickler. Lizenz - Apache License 2.0 - Diese App ist Open Source und wird unter der Apache License 2.0 veröffentlicht. Nutzung, Veränderung, Forks und Weitergabe sind unter den Bedingungen dieser Lizenz erlaubt. Copyright- und Lizenzhinweise müssen erhalten bleiben. Die Software wird ohne Gewährleistung bereitgestellt. + Apache License 2.0 + Commons Clause + GPS2Audio-eigener Code, Assets und Branding stehen unter der Apache License 2.0 mit der Commons Clause License Condition v1.0. Die Apache-2.0-Rechte (Nutzung, Modifikation, Forks, Weitergabe) bleiben erhalten — Copyright- und Lizenzhinweise müssen mitgegeben werden. Die Commons Clause schließt jedoch ausdrücklich das Recht zum Verkauf der Software oder zum Betrieb als kommerziellen/gehosteten Dienst aus (siehe Nutzungsbedingungen und TERMS.md). Drittanbieter-Bibliotheken behalten ihre jeweiligen Originallizenzen (siehe NOTICE / THIRD_PARTY_NOTICES). Die Software wird ohne Gewährleistung bereitgestellt. https://www.apache.org/licenses/LICENSE-2.0 + Dieser Hinweis ersetzt keine Rechtsberatung. Maßgeblich ist im Zweifel der Originaltext der Apache License 2.0. Schließen Copyright 2026 Marcel Mayer - - Matrix-Kontakt - Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren. - @neso:nesohub.org - https://matrix.to/#/@neso:nesohub.org - Matrix-Chat öffnen - Matrix-Chat mit @neso:nesohub.org öffnen - QR-Code für Matrix-Kontakt @neso:nesohub.org + + Lizenz & Nutzung + GPS2Audio Profi + Die GPS2Audio Profi-Lizenz ist eine einjährig gültige Lizenz für professionelle Anwender, Reiseleiter, Institutionen und Anbieter geführter Touren. + Sie ermöglicht professionelle Nutzung und unterstützt die kontinuierliche Weiterentwicklung der App. + Die Lizenz läuft nicht automatisch weiter und kann bei Bedarf aktiv erneuert werden. Laufzeit und Konditionen werden in der Lizenzdatei hinterlegt. + Für professionelle Nutzung vorgesehen: + • Multi-Clip-Waypoints\n• Live/PTT-Funktion\n• Tour-Zähler + Typische Einsatzbereiche: + • Kommerzielle Nutzung und bezahlte Touren + • Institutionelle Nutzung (Behörden, Tourismusverbände, Reiseveranstalter, Bildungsträger) + • Kundenprojekte und Whitelabel-/Auftragsarbeiten + • Professionelle Reiseleitung und geführte Touren + Die private und interne (nicht-kommerzielle) Nutzung bleibt gemäß den Lizenzbedingungen (Apache 2.0 + Commons Clause) möglich. + Anfragen werden zeitnah bearbeitet. Bitte Lizenznehmer-Name, Firma/Institution und Verwendungszweck angeben. + Profi-Lizenz per E-Mail anfragen + E-Mail-App für Profi-Lizenz-Anfrage öffnen + marcel.mayer@nesohub.org + GPS2Audio Profi-Lizenz anfragen + Hallo, +ich interessiere mich für eine GPS2Audio Profi-Lizenz. + +Lizenznehmer/Firma: +Ansprechpartner: +E-Mail: +Nutzung: professionell / institutionell / Testversion +Geräte-ID: %s +Bemerkung: + +Viele Grüße + Keine E-Mail-App gefunden. Bitte senden Sie eine E-Mail an marcel.mayer@nesohub.org mit Ihrem Namen, Ihrer Firma und dem Verwendungszweck. + Empfänger: marcel.mayer@nesohub.org + + + Lizenzstatus + Status + Keine Lizenz importiert + Lizenznehmer + - + Gültigkeit + - + Freigeschaltete Funktionen + Basisfunktionen + Eine importierte Pro-Lizenzdatei wird hier künftig angezeigt. Bis dahin sind alle bestehenden Funktionen ohne Einschränkung verfügbar. + + + Lizenzaktionen + Lizenz importieren + Lizenz exportieren + Lizenz entfernen + Lizenz entfernen? + Die importierte Lizenz wird lokal gelöscht. Die App fällt auf „Keine Lizenz importiert" zurück. Bestehende Touren und Mediendateien bleiben unverändert. + Entfernen + Abbrechen + Lizenz entfernt + Lizenzdatei konnte nicht entfernt werden + Lizenz importiert: %1$s + Lizenz ungültig: %1$s + Ungültige Lizenzdatei: %1$s + Lizenzdatei konnte nicht gelesen werden + Datei konnte nicht geöffnet werden. Bitte erneut versuchen. + Datei ist leer oder enthält keine Daten. + Keine Leseberechtigung für diese Datei. Bitte Datei-Manager verwenden. + Lesefehler beim Zugriff auf die Datei. Bitte erneut versuchen. + Kein Datei-Manager verfügbar. Bitte einen Datei-Manager installieren. + gps2audio.license.json + Lizenz exportiert + Lizenz konnte nicht exportiert werden + Keine importierte Lizenz vorhanden + Lizenztyp + Lizenz-ID + Bindung + Berechtigungen + - + + Lizenz-Geräte-ID + Für optionale gerätegebundene Lizenzen. Nicht im normalen Backup enthalten. Wenn App-Daten gelöscht werden, ändert sich die ID. + Geräte-ID + GPS2Audio Geräte-ID + ID kopieren + Geräte-ID in Zwischenablage kopiert + + + GPS2Audio aktualisiert + Neue Version: %1$s + Neu in 2.5.12: Wegpunkte können jetzt mehrere Audio-Clips enthalten. Jeder Clip hat eigenen Titel, kann einzeln aktiviert/deaktiviert, ersetzt, gelöscht und in der Reihenfolge sortiert werden. Im Tour-Play werden alle aktiven Clips eines Wegpunkts nacheinander abgespielt, inaktive übersprungen. Einzelabspielung des Hauptclips bleibt direkt aus der Liste verfügbar. Backup/Restore, PTT, Atmo/Stream, Foreground-Service und Karten-/Routen-Funktionen sind unverändert; alte Wegpunkte mit nur einer Audiodatei laden ohne Migration weiter. + 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.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. + + + Explorer + Editor + Zum Editor wechseln + Zum Explorer wechseln + Karte + Karten-Ebenen + Ebenen / 3D + Standort folgen + Wegpunkt-Marker + Audio-Radius / Reichweite + GPS-Track / Draft-Linien + 3D-Modus + 3D in Vorbereitung — MapLibre ist in v2.2.2 als Build-Abhängigkeit verdrahtet, aktives Rendering läuft aber weiterhin auf osmdroid (2D-Raster). + Echtes 3D (Vektor-Terrain mit DEM-Höhendaten) ist in v2.3.0 noch nicht aktiv – die App verwendet weiterhin einen Raster-Renderer (osmdroid) mit MapLibre-Tile-Anbindung. Der Perspektiv-Modus bietet stattdessen eine geneigte Kameraansicht als Vorschau auf den späteren MapLibre-3D-Renderer. + Schließen + + Basis-Karte + Ebenen + + Modern + Dunkel + Outdoor + 3D Stadt + Klassisch + + Outdoor + 3D Stadt + Satellit + 3D / Perspektive + + Modern (OpenFreeMap Positron): Heller, reduzierter Vektorstil. Keine API-Key-Pflicht, keine Nutzungsgrenzen. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL). + Dunkel (OpenFreeMap Dark Matter): Echter dunkler Vektorstil. Keine API-Key-Pflicht. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL). + Dunkler Vektorstil (OpenFreeMap Dark) — kein API-Key, kostenlos. + Outdoor (OpenFreeMap Liberty): Lebendiger Vektorstil mit ausgeprägter Gelände-, Grün- und Wegenetz-Darstellung. Ideal für Wandern und Außenbereiche. Keine API-Key-Pflicht. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenStreetMap-Mitwirkende (ODbL). + Outdoor-Vektorstil (OpenFreeMap Liberty) — Gelände und Wegenetze. + 3D Stadt (OpenFreeMap Positron + Gebäude-Extrusion): Heller Vektorstil mit dreidimensional dargestellten Gebäuden via MapLibre fill-extrusion. Camera-Pitch einstellbar. Wenn keine building-Quelle im Stil vorhanden, automatischer 2D-Fallback ohne Absturz. Keine API-Key-Pflicht. Quelle: openfreemap.org. + 3D-Stadtansicht mit Gebäude-Extrusion — kein API-Key, kostenlos. Bei fehlendem building-Layer: 2D-Fallback. + Klassisch (OSM-Raster): Standard-OpenStreetMap-Rastertiles (tile.openstreetmap.org). Attribution: © OpenStreetMap-Mitwirkende (ODbL). Kein Vektor, kein 3D. + + Karte: © OpenFreeMap Liberty, Daten: © OpenStreetMap-Mitwirkende + Outdoor-Stil-Quelle + OpenFreeMap Liberty wird automatisch verwendet. + Outdoor-Quelle nicht konfiguriert + Outdoor-Stil (OpenFreeMap Liberty): Kostenlos, kein API-Key. Attribution sichtbar. + 3D-Stadt Pitch-Einstellung + Neigungswinkel für den 3D-Stadtmodus. + 3D-Stadtansicht aktiv (Positron + Gebäude-Extrusion). + 3D Stadt: OpenFreeMap Positron mit Gebäude-Extrusion. Camera-Pitch einstellbar. Kein API-Key benötigt. + Neigung (Pitch) + Audio-Länge je Wegpunkt + Eigene Satelliten-Tile-URL + Optional. XYZ-Format mit {z}/{x}/{y}. Bei leerem Feld wird die Standard-Quelle (ESRI World Imagery) verwendet – siehe Quellenhinweis auf der Karte. + 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. + 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. + Tiles © Esri + Satellit: eigene Tile-Quelle + Audio-Dauer: %1$s + + + Explorer + Explorer öffnen + Karte · Standort · Wegpunkte (read-only) + Standort nicht verfügbar — Berechtigung oder GPS prüfen. + Tippe „Mein Standort", um die Karte auf deine Position zu zentrieren. Wegpunkte der aktuellen Tour werden angezeigt. + Nachführung an + Nachführung aus + In Karteneditor öffnen + Auswahl übernehmen + Übernahme folgt im nächsten Schritt. + Explorer ist ein reiner Vorerkundungs-Modus — keine PTT, keine Atmo, keine Aufzeichnung. + %1$d Wegpunkte sichtbar + Mein Standort + Genauigkeit: %1$d m + + + Karte · Standort · Wegpunkte bearbeiten + Wegpunkt + Wegpunkt bearbeiten + Position per Tap setzen + Auf Kartenmitte verschieben + Neuer Wegpunkt per Tap + Tippe auf die Karte, um einen neuen Wegpunkt zu setzen. + Tippe die neue Position an — der gewählte Wegpunkt wird dorthin verschoben. + Tap-Modus abbrechen + Karteneditor (Track / Drafts) + + + Karte · Standort · Wegpunkte · GPS-Aufzeichnung · Drafts · Storyboard + 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. + Zieltour (Name) + Keine Wegpunkte in der aktuellen Tour. Lege erst welche 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. + 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. + + + Karten-Engine: osmdroid (OSM-Raster) + Karten-Engine: MapLibre-Anbindung vorbereitet · Aktiv: osmdroid + + Für diese Version nicht mehr anzeigen + OK + + + Abspielregeln @@ -192,11 +462,19 @@ Playlist Noch keine Audiodateien ausgewählt. + Noch keine Audiodateien für diese Tour ausgewählt – wähle neue Dateien hinzu oder nimm bekannte aus der Bibliothek. Dateien hinzufügen + Aus Bibliothek auswählen Playlist leeren Entfernen Zufällige Reihenfolge + + Bibliothek auswählen + Audiodateien, die bereits zu dieser App hinzugefügt wurden – aus Wegpunkten oder aus den Playlists anderer Touren. Auswahl wird zur aktuellen Playlist hinzugefügt. + Alle bekannten Audiodateien sind bereits in dieser Playlist. + Hinzufügen + Stream-URL Direkte http/https-Audio-URL erforderlich (z. B. Internetradio) @@ -204,6 +482,22 @@ URL muss mit http:// oder https:// beginnen Hinweis: YouTube, SoundCloud und radio.de geben keine direkt abspielbaren Audio-URLs aus. Bitte eine direkte Stream-URL (z. B. von Internetradio-Anbietern) eingeben. Seiten-Links funktionieren nicht. + + Stream-Playlist + Noch keine Streams hinzugefügt – unten URL eingeben und „Hinzufügen“ antippen. + Bitte mindestens einen Stream zur Playlist hinzufügen. + Stream hinzufügen + Stream bearbeiten + Name (optional) + z. B. Deutschlandfunk Kultur + Hinzufügen + Übernehmen + Playlist leeren + Bearbeiten + Entfernen + Nach oben + Nach unten + Verhalten bei Wegpunkt-Audio Pausieren und fortsetzen @@ -307,4 +601,354 @@ Zähler beim Anwenden zurücksetzen Auf alle Wegpunkte anwenden Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe. + + + Reihenfolge bearbeiten + Fertig + Reihenfolge bearbeiten ist während GPS-Dienst oder Track-Aufzeichnung deaktiviert. + Sortiermodus aktiv – ziehe ▲▼ zum Verschieben. + Nach oben verschieben + Nach unten verschieben + Touren-Reihenfolge + + + Touren ordnen + Reihenfolge der Touren festlegen. Pfeile zum Verschieben. + Sortieren während GPS/Aufzeichnung deaktiviert + + + Ziehgriff zum Verschieben + Ziehgriff halten und verschieben + + + Display aktiv halten + Display aktiv halten + Verhindert, dass sich das Display während einer Tour sperrt. Erhöht den Akkuverbrauch. + Nie + Immer wenn App im Vordergrund + Nur bei aktiver Tour/GPS + Hinweis zum Akkuverbrauch + Wenn das Display dauerhaft an bleibt, steigt der Akkuverbrauch deutlich. Schließe das Telefon bei längeren Touren möglichst an eine Ladequelle an. + Verstanden + + + Karten-Anbieter + Karten-Anbieter wählen + OpenStreetMap (Standard) + CartoDB Positron (hell) + CartoDB Dark Matter (dunkel) + OpenTopoMap (topografisch) + ESRI Satellitenbild + ESRI Satellit + Beschriftung + Stadia Alidade + Stadia Outdoors + + + Live / Aufnahme + Live-Ausgabe + Mikrofon wird über das Ausgabegerät hörbar gemacht. + Aufnahme speichern + PTT-Mitschnitt wird als Audiodatei gespeichert (M4A/AAC). + Aufnahme-Format + M4A/AAC + WAV (in v2.1.0 nicht verfügbar) + Mindestens „Live-Ausgabe" oder „Aufnahme speichern" muss aktiv sein. Live wurde automatisch aktiviert. + Aufnahmen… + Aufnahme läuft + Aufnahme gespeichert + Datei: %1$s + Dauer: %1$s + GPS: %1$.5f, %2$.5f + Als Waypoint-Audio zuweisen + Löschen + OK + PTT-Aufnahmen + Noch keine Aufnahmen.\nAktiviere „Aufnahme speichern" in den PTT-Einstellungen und starte PTT. + %1$s · %2$s + Aufnahme löschen? + „%1$s" wird unwiderruflich gelöscht. + Wegpunkt auswählen + Keine Wegpunkte in dieser Tour. + Du kannst die Aufnahme direkt als neuen Wegpunkt in der gewählten Tour anlegen. + Tour: %1$s + Neuen Wegpunkt aus Aufnahme erstellen + Wegpunkt „%1$s" in Tour „%2$s" angelegt. + Audio dem Wegpunkt „%1$s" zugewiesen. + Anhören + Pause + PTT-Aufnahme gespeichert: %1$s (%2$s) + Schließen + + + Clip abspielen + Clip pausieren + Kein Clip hinterlegt + + + Karte · %1$d Wegpunkte + REC · %1$d + Draft · %1$d/%2$d + REC + Stop + + + Clipabschnitte entlang Route/Track + Wird nur gezeichnet, wenn ein aufgezeichneter Track/Draft oder eine berechnete Route vorliegt — keine freien Projektionen mehr. + ≤ 15s kurz + 15–60s mittel + ≥ 60s lang + + + MapLibre · Engine v2.4 + + + Routing & POI + Routing & Online-POI (MVP) + POI-Suche (online) + z. B. „bäckerei", „museum", „bahnhof" + Suchen + Suche läuft … + Keine POIs gefunden. + Suche fehlgeschlagen — bitte Verbindung/Server prüfen. + POI-Quelle: OpenStreetMap · Overpass/Nominatim · Bitte sparsam abfragen. + Als Wegpunkt + Ins Storyboard + %1$s entfernt + 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. + Demo-OSRM-Server (router.project-osrm.org) hat Rate-Limits und ist nicht für Produktion gedacht. + Route berechnen + 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 + Name der neuen Tour + Punkte als Wegpunkte übernehmen + %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. + Start + Via %1$d + Ziel + Quelle wählen + Aktueller Standort + Aktueller Standort: noch keine Position + Kartenmitte + Vorhandenen Wegpunkt wählen + Keine Wegpunkte in dieser Tour + Online-POI suchen + Eigene Koordinate eingeben + Ortssuche (Nominatim) + Koordinate eingeben + Breitengrad (Lat) + Längengrad (Lon) + Übernehmen + Ungültige Koordinaten. + Via entfernen + Nach oben + Nach unten + 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. + 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 + Planlinie (Luftlinie) + Als Start + Als Via + Als Ziel + Punkt verwenden + Treffer wird als Stopp dem Routenplaner hinzugefügt. + Gesuchten Ort übernehmen + Routing optional via OSRM-kompatiblem Endpunkt — 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. + Profil: %1$s + + + Tourroute „%1$s" + Keine Tourroute 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). + 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 + 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 + 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. + + + Nutzungsbedingungen + GPS2Audio ist source-available. Vor der ersten Nutzung musst du den folgenden Bedingungen zustimmen. Ohne Zustimmung kann die App nicht verwendet werden. + Lizenz + Code, Branding und Assets von GPS2Audio stehen unter Apache License 2.0 mit Commons Clause License Condition v1.0. Drittanbieter-Bibliotheken behalten ihre jeweilige Originallizenz (siehe NOTICE / THIRD_PARTY_NOTICES). + Erlaubt / nicht erlaubt + Private und nicht-kommerzielle Nutzung ist frei. Kommerzielle Nutzung – insbesondere Verkauf, SaaS/Hosted-Service, bezahlte Touren (z. B. Bus-, Schiff-, Stadt-, Wander- oder Radführungen) sowie Integration in kommerzielle Angebote – ist nur mit ausdrücklicher schriftlicher Genehmigung des Entwicklers zulässig. + Haftungsausschluss + Die App wird ohne Gewährleistung bereitgestellt. GPS2Audio ist kein Sicherheits- oder Navigationssystem. Nutze die App nicht während Tätigkeiten, bei denen Unaufmerksamkeit zu Personen- oder Sachschäden führen kann. + Version 2.5.11-terms-1 · Vollständiger Text in TERMS.md + Ich stimme zu + Ablehnen und beenden + + + Hintergrundwiedergabe absichern + Damit die Tour-Wiedergabe auch im Standby weiterläuft, empfehlen wir kurz einen Blick in die Systemeinstellungen. Du kannst die Wiedergabe trotzdem sofort starten — der Hinweis erscheint nur einmal pro Version. + Empfehlung für Xiaomi / Redmi / Poco (MIUI / HyperOS) + Akku & Leistung: GPS2Audio auf „Keine Einschränkungen“ stellen. + Hintergrund-Autostart für GPS2Audio erlauben. + In der Recent-Apps-Übersicht das Schloss-Symbol über der GPS2Audio-Karte aktivieren. + Benachrichtigungen für GPS2Audio erlauben. + Empfehlung + Akkuoptimierung für GPS2Audio deaktivieren. + Benachrichtigungen für GPS2Audio erlauben. + Status: Akkuoptimierung für GPS2Audio ist aktiv — Wiedergabe kann im Standby gestoppt werden. + Status: Akkuoptimierung für GPS2Audio ist ausgenommen. + Akkuoptimierung öffnen + App-Einstellungen öffnen + Benachrichtigungen öffnen + MIUI-Autostart öffnen + Für diese Version nicht mehr anzeigen + Verstanden + Hintergrundwiedergabe + Tipps, falls die Wiedergabe im Standby stoppt + + + + + Lizenz aus Zwischenablage / Text einfügen + Lizenztext einfügen + Fallback für Geräte, auf denen der Datei-Import nicht funktioniert. Öffne die .gps2audio-license-Datei in einem Texteditor, kopiere den gesamten Inhalt und füge ihn hier ein. + JSON-Lizenztext + Aus Zwischenablage einfügen + Zwischenablage ist leer. + Kein Text eingegeben. + Importieren + + Datei konnte nicht gelesen werden + Alle Lesestrategien sind fehlgeschlagen. Technische Details: + Tipp: Versuche den Textimport ("Lizenz aus Zwischenablage / Text einfügen") als Alternative. + + Neu in 2.5.24: Lizenzimport-Fallbacks. Datei-Import nutzt jetzt drei Lesestrategien (openInputStream, openFileDescriptor, File-Fallback). Bei Totalausfall erscheint ein Diagnose-Dialog mit technischem Fehlerdetail statt eines generischen Fehlers. Neuer Textimport-Button: JSON-Inhalt der Lizenzdatei direkt einfügen (Fallback für MIUI/OEM-Dateimanager). Dateiauswahl nutzt OpenDocument() mit nur "*/*" für maximale Datei-Browser-Kompatibilität. importNonce-Mechanismus aus v2.5.23 unverändert beibehalten. Alle anderen Funktionen unverändert. + + Neu in 2.5.37: Explorer Map Styles. Alle Kartenstile nutzen jetzt echte MapLibre-Vektordaten via OpenFreeMap (kostenlos, kein API-Key). Modern: OpenFreeMap Positron (hell, reduziert). Dunkel: OpenFreeMap Dark Matter (echter Vektor-Dark-Style). Outdoor: OpenFreeMap Liberty (Gelände, Wege, Grünflächen). 3D Stadt: Positron + MapLibre fill-extrusion für Gebäude (Camera-Pitch einstellbar; bei fehlendem building-Layer: 2D-Fallback ohne Absturz). Klassisch: OSM-Rastertiles als Fallback. Satellit nicht mehr im UI. Stilwahl wird persistent gespeichert. Attribution bleibt sichtbar. + Neu in 2.5.29: Lizenz-Persistenz-Fix. Eine importierte Lizenz überlebt jetzt normale APK-Updates zuverlässig. Ursache: Nach einem Update wurde der Lizenzstatus erst beim Öffnen des Über-Dialogs geladen — die Lizenzanzeige zeigte daher nach dem Update kurzzeitig „Keine Lizenz importiert", obwohl die Datei intakt war. Fix: Lizenz wird jetzt beim App-Start im Hintergrund geladen (WaypointApp.onCreate). Zusätzlich wird das Lizenz-JSON redundant in den SharedPreferences gesichert (self-healing Fallback). Explicit „Lizenz entfernen" löscht weiterhin beide Speicher. Backup-Ausschluss (kein Cloud-Backup) bleibt unverändert. Alle anderen Funktionen unverändert. + + + Zusatzklausel + Commons Clause License Condition v1.0 + Die Apache 2.0-Rechte schließen ausdrücklich NICHT das Recht ein, GPS2Audio zu verkaufen oder als kommerziellen/gehosteten Dienst (SaaS, bezahlte Touren, B2B-Whitelabel) anzubieten. Für solche Nutzungen ist eine separate schriftliche Genehmigung erforderlich. + Drittanbieter-Komponenten + Drittanbieter-Bibliotheken (AndroidX, Material3, ExoPlayer/Media3, osmdroid, MapLibre, Gson u. a.) bleiben unter ihren jeweiligen Originallizenzen. Siehe NOTICE.md / THIRD_PARTY_NOTICES.md im Quellpaket. + + + © OpenStreetMap · ODbL + Karteninfo + Karteninfo + Kartenstil + OpenFreeMap / OpenMapTiles + Kartendaten + © OpenStreetMap-Mitwirkende, ODbL + Quellen + openfreemap.org + openmaptiles.org + openstreetmap.org/copyright + Schließen + Karteninfo + + + POI anzeigen + Kategorien aktivieren, dann unten auf der Karte „POIs laden“ tippen. Daten aus OpenStreetMap/Overpass — kein API-Key, rate-limit-freundlich. + Sehenswürdigkeiten + Infrastruktur + Gastronomie + Mobilität + POI-Layer nur bei Vektor-Stilen (Modern, Dunkel, Outdoor, 3D Stadt) verfügbar. Im Klassisch-Modus (Raster) nicht anwendbar. + + + Neu in 2.5.38: Kartenattribution rechtlich sauber umgesetzt — kompaktes Badge unten links mit Link auf openstreetmap.org/copyright, Info-Dialog über Karteninfo-Schaltfläche mit vollständigen Angaben (OpenFreeMap, OpenMapTiles, OSM-Mitwirkende, ODbL). POI-Layer im Explorer eingebaut: Sehenswürdigkeiten, Infrastruktur, Gastronomie, Mobilität — schaltbar per Chips im Ebenen-Sheet, Auswahl persistent gespeichert. Kartenstil-Auswahl wird zuverlässig über App-Neustarts hinweg persistiert (DataStore). Alle Stile, Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, PTT, Tour-Zähler, Backup, GPX-Import, Routing unverändert. + + + POIs im Kartenbereich laden + POIs werden geladen … + Keine POIs in diesem Bereich gefunden. + Kartenausschnitt zu groß – Abfrage auf max. 5 km Radius begrenzt. + Nach Norden ausrichten + Kategorie + Entfernung + OSM-Tags + POI-Daten: OpenStreetMap/Overpass (ODbL) + Als Wegpunkt + Schließen + 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. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c7e6d59..3a7d60c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,17 @@ + + +