Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
-11
@@ -1,11 +0,0 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/build/
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 **Versionupgrades
|
||||
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.
|
||||
@@ -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.
|
||||
+182
-2
@@ -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/<tour>.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)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Foreground service with microphone access (Android 14+) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<!-- Foreground service for background music playback (Atmo/Begleitmusik, Android 14+) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Audio (MediaPlayer needs no special permission for local files) -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
@@ -38,11 +40,13 @@
|
||||
<application
|
||||
android:name=".WaypointApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WaypointAudioGuide">
|
||||
android:theme="@style/Theme.WaypointAudioGuide.Splash">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -75,6 +79,23 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<!-- Atmo/Begleitmusik foreground service: hält die Hintergrund-Audio-
|
||||
Wiedergabe (Stream oder lokale Playlist) lebendig, wenn die App
|
||||
minimiert wird oder der Bildschirm sich abschaltet. -->
|
||||
<service
|
||||
android:name=".service.AtmoPlaybackService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- v2.5.10 — Manuelle Wiedergabe (Tour-/Trackplay aus Wegpunkt-Liste).
|
||||
Hält die MediaPlayer-Wiedergabe stabil, wenn der Bildschirm abschaltet. -->
|
||||
<service
|
||||
android:name=".service.ManualPlaybackService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- File provider for sound file selection (Android 7+) -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -8,14 +8,32 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import de.waypointaudio.data.TermsStore
|
||||
import de.waypointaudio.data.TermsVersions
|
||||
import de.waypointaudio.data.WakeLockSettingsStore
|
||||
import de.waypointaudio.service.WakeLockController
|
||||
import de.waypointaudio.ui.TermsDialog
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import de.waypointaudio.ui.ExplorerScreen
|
||||
import de.waypointaudio.ui.ExplorerScreenMapLibre
|
||||
import de.waypointaudio.ui.HomeScreen
|
||||
import de.waypointaudio.ui.MapMode
|
||||
import de.waypointaudio.ui.MapScreen
|
||||
import de.waypointaudio.ui.PermissionRequiredScreen
|
||||
import de.waypointaudio.ui.TrackDraftEditorScreen
|
||||
import de.waypointaudio.ui.TrackDraftListScreen
|
||||
import de.waypointaudio.ui.VersionNoticeDialogHost
|
||||
import de.waypointaudio.ui.WaypointListScreen
|
||||
import de.waypointaudio.ui.theme.WaypointAudioTheme
|
||||
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||
@@ -40,6 +58,16 @@ class MainActivity : ComponentActivity() {
|
||||
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
|
||||
private val locationPermissionsGranted = mutableStateOf(false)
|
||||
|
||||
/** Hält das Display abhängig von der Nutzer-Einstellung aktiv. */
|
||||
lateinit var wakeLockController: WakeLockController
|
||||
private set
|
||||
|
||||
/**
|
||||
* v2.5.9 — Terms-Akzeptanz. -1 = noch nicht geladen, 0 = noch nicht akzeptiert
|
||||
* (Dialog zeigen), 1 = akzeptiert (UI freigeben).
|
||||
*/
|
||||
private val termsState = mutableStateOf(-1)
|
||||
|
||||
// Launcher für Standortberechtigungen (zwingend erforderlich)
|
||||
private val locationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
@@ -60,31 +88,157 @@ class MainActivity : ComponentActivity() {
|
||||
) { /* optional – kein Block der Hauptoberfläche */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// v2.5.8 — Android Splash Screen (androidx.core.splashscreen). MUSS vor
|
||||
// super.onCreate() installiert werden. Sorgt für ein kurzes, dezentes
|
||||
// GPS2Audio-Intro mit Logo (Map-Pin + Wellen) auf Navy-Hintergrund.
|
||||
// Wir halten den Splash bewusst nur ca. 700 ms — kein Loading-Spinner,
|
||||
// keine Werbe-Pause. Auf API 31+ animiert das System das Logo automatisch
|
||||
// beim Übergang. Auf API 26–30 nutzt der Backport dieselbe Optik.
|
||||
val splash = installSplashScreen()
|
||||
val splashStart = android.os.SystemClock.elapsedRealtime()
|
||||
splash.setKeepOnScreenCondition {
|
||||
android.os.SystemClock.elapsedRealtime() - splashStart < 700L
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Wake-Lock-Controller initialisieren
|
||||
wakeLockController = WakeLockController(this)
|
||||
val wakeStore = WakeLockSettingsStore(applicationContext)
|
||||
lifecycleScope.launchWhenStarted {
|
||||
wakeStore.settings.collect { settings ->
|
||||
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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Long> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ExplorerSettings> = context.explorerDataStore.data.map { prefs ->
|
||||
ExplorerSettings(
|
||||
followLocation = prefs[KEY_FOLLOW] ?: true
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setFollowLocation(enabled: Boolean) {
|
||||
context.explorerDataStore.edit { prefs ->
|
||||
prefs[KEY_FOLLOW] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HomeModulesSettings> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<MapProvider> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MapStyleSettings> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Waypoint>.toPlayableSequence(): List<PlayableItem> {
|
||||
val waypointsWithClips = this
|
||||
.map { wp -> wp to wp.activeClips() }
|
||||
.filter { it.second.isNotEmpty() }
|
||||
val waypointTotal = waypointsWithClips.size
|
||||
val items = mutableListOf<PlayableItem>()
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
* `<filesDir>/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<List<PttRecording>>() {}.type
|
||||
|
||||
/** Reaktiver Flow aller Aufnahme-Metadaten (neueste zuerst, sortiert nach createdAt desc). */
|
||||
val recordings: Flow<List<PttRecording>> = context.pttRecordingsDataStore.data
|
||||
.map { prefs ->
|
||||
val raw = prefs[KEY_ENTRIES]
|
||||
if (raw.isNullOrBlank()) emptyList()
|
||||
else runCatching {
|
||||
gson.fromJson<List<PttRecording>>(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<PttRecording> {
|
||||
var result: List<PttRecording> = emptyList()
|
||||
context.pttRecordingsDataStore.edit { prefs ->
|
||||
val raw = prefs[KEY_ENTRIES]
|
||||
result = if (raw.isNullOrBlank()) emptyList()
|
||||
else runCatching {
|
||||
gson.fromJson<List<PttRecording>>(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<List<PttRecording>>(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<List<PttRecording>>(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<List<PttRecording>>(raw, listType) ?: emptyList()
|
||||
}.getOrElse { emptyList() }
|
||||
prefs[KEY_ENTRIES] = gson.toJson(list.map {
|
||||
if (it.id == id) it.copy(displayName = newName) else it
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = context.termsDataStore.data
|
||||
.map { it[KEY_ACCEPTED_VERSION] ?: "" }
|
||||
|
||||
suspend fun setAccepted(version: String) {
|
||||
context.termsDataStore.edit { prefs ->
|
||||
prefs[KEY_ACCEPTED_VERSION] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PlaylistItem> = emptyList(),
|
||||
val streamPlaylist: List<StreamEntry> = 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<StreamEntry>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -34,7 +34,7 @@ class TourMusicStore(private val context: Context) {
|
||||
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
|
||||
runCatching {
|
||||
val raw: Map<String, TourAudioSettingsJson>? = 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<PlaylistItemJson> = emptyList(),
|
||||
val streamPlaylist: List<StreamEntryJson>? = 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,
|
||||
|
||||
@@ -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<TourRouteStop> = emptyList(),
|
||||
val polyline: List<TourRoutePoint> = 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
|
||||
}
|
||||
@@ -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<GpsTrackPoint> = emptyList(),
|
||||
val markedIndexes: List<Int> = emptyList(),
|
||||
val notes: String = ""
|
||||
) {
|
||||
/** Punktanzahl. */
|
||||
val pointCount: Int get() = points.size
|
||||
|
||||
/** Anzahl markierter (für Import vorgesehener) Punkte. */
|
||||
val markedCount: Int get() = markedIndexes.size
|
||||
}
|
||||
@@ -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<Long> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WakeLockSettings> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AudioClip> = 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<AudioClip> {
|
||||
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<AudioClip> =
|
||||
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<AudioClip>): 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<WaypointListViewSettings> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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<List<TrackDraft>>() {}.type
|
||||
private val tourRoutesMapType = object : TypeToken<Map<String, TourRoute>>() {}.type
|
||||
private val tourCoversMapType = object : TypeToken<Map<String, TourCover>>() {}.type
|
||||
|
||||
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
|
||||
val waypointsFlow: Flow<List<Waypoint>> = 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<Map<String, TourRoute>> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
|
||||
runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(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<Map<String, TourCover>> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
|
||||
runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType) ?: emptyMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
|
||||
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
|
||||
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<List<TrackDraft>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
|
||||
runCatching {
|
||||
gson.fromJson<List<TrackDraft>>(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<TrackDraft> = runCatching {
|
||||
gson.fromJson<List<TrackDraft>>(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<TrackDraft> = runCatching {
|
||||
gson.fromJson<List<TrackDraft>>(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<TrackDraft> = runCatching {
|
||||
gson.fromJson<List<TrackDraft>>(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<Waypoint>) {
|
||||
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<String, TourRoute> = runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(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<String, TourRoute> = runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(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<String, TourRoute> = runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(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<String, TourRoute>) {
|
||||
if (routes.isEmpty()) return
|
||||
context.dataStore.edit { prefs ->
|
||||
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
|
||||
val map: MutableMap<String, TourRoute> = runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(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<String, TourCover> = runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(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<String, TourCover> = runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(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<String, TourCover> = runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(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<String, TourCover>) {
|
||||
if (covers.isEmpty()) return
|
||||
context.dataStore.edit { prefs ->
|
||||
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
|
||||
val map: MutableMap<String, TourCover> = runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(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 ->
|
||||
|
||||
@@ -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/<tourname>.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/<idx>_<tourname>.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<Waypoint> = emptyList(),
|
||||
val tours: List<String> = emptyList(),
|
||||
val tourDefaults: Map<String, TourPlaybackDefaults> = emptyMap(),
|
||||
val gpsTracks: Map<String, List<GpsTrackPoint>> = emptyMap(),
|
||||
val musicSettings: Map<String, TourAudioSettings> = emptyMap(),
|
||||
// v2.4.6 — Tourrouten gehören zur Tour und werden mit ihr exportiert.
|
||||
val tourRoutes: Map<String, TourRoute> = 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<String, TourCover> = 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<String> = 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<MediaEntry>()
|
||||
val mediaMissing = mutableListOf<MediaEntry>()
|
||||
val sectionsWritten = mutableListOf<String>()
|
||||
|
||||
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/<idx>_<tour>.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<String, TourCover>()
|
||||
val coverImagesCopied = mutableListOf<String>()
|
||||
// Menge bereits verwendeter ZIP-Pfade zur Kollisionsvermeidung.
|
||||
val usedZipPaths = mutableSetOf<String>()
|
||||
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<MediaSource>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = emptyList(),
|
||||
val newTourSelection: String? = null
|
||||
)
|
||||
|
||||
/** Ergebnis-Daten, das der Aufrufer (ViewModel) in die DataStores schreibt. */
|
||||
data class ImportPayload(
|
||||
val mergedWaypoints: List<Waypoint>,
|
||||
val mergedTours: List<String>,
|
||||
val tourDefaultsToWrite: Map<String, TourPlaybackDefaults>,
|
||||
val tracksToWrite: Map<String, List<GpsTrackPoint>>,
|
||||
val musicToWrite: Map<String, TourAudioSettings>,
|
||||
// v2.4.6 — Tourrouten, die geschrieben werden sollen (Map endgültiger Tourname → Route).
|
||||
val tourRoutesToWrite: Map<String, TourRoute> = emptyMap(),
|
||||
// v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover).
|
||||
val tourCoversToWrite: Map<String, TourCover> = emptyMap(),
|
||||
val trackMinDistanceMeters: Int?,
|
||||
val newTourSelection: String?,
|
||||
val warnings: List<String>,
|
||||
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<Waypoint>,
|
||||
existingTours: List<String>,
|
||||
existingTourDefaults: Map<String, TourPlaybackDefaults>,
|
||||
existingTracks: Map<String, List<GpsTrackPoint>>,
|
||||
existingMusic: Map<String, TourAudioSettings>,
|
||||
// v2.4.6 — bestehende Tourrouten, damit wir keine bereits vorhandene Route überschreiben.
|
||||
existingTourRoutes: Map<String, TourRoute> = emptyMap(),
|
||||
// v2.5.5 — bestehende Tour-Cover, damit eigene Auswahl nicht überschrieben wird.
|
||||
existingTourCovers: Map<String, TourCover> = emptyMap()
|
||||
): Pair<ImportPayload?, String?> {
|
||||
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<String, String>()
|
||||
// Mapping originaler displayName/dateiname → zip-pfade (für Best-Effort-Match)
|
||||
val mediaByLeafName = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
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<String>()
|
||||
|
||||
// ---- Tours + Waypoints ----
|
||||
val resultTours = existingTours.toMutableList()
|
||||
// Mapping: original importierter Tourname → endgültiger Name (ggf. umbenannt)
|
||||
val tourRename = mutableMapOf<String, String>()
|
||||
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<String, TourPlaybackDefaults>()
|
||||
var tourDefaultsImported = 0
|
||||
// v2.4.6 — Tourrouten Merge-Buffer.
|
||||
val tourRoutesToWrite = mutableMapOf<String, TourRoute>()
|
||||
var tourRoutesImported = 0
|
||||
// v2.5.5 — Tour-Cover Merge-Buffer.
|
||||
val tourCoversToWrite = mutableMapOf<String, TourCover>()
|
||||
var tourCoversImported = 0
|
||||
|
||||
if (selection.toursAndWaypoints && toursJson != null) {
|
||||
val payload = runCatching {
|
||||
JsonParser.parseString(toursJson).asJsonObject
|
||||
}.getOrNull()
|
||||
if (payload != null) {
|
||||
val importedTourList: List<String> = runCatching {
|
||||
gson.fromJson<List<String>>(
|
||||
payload.get("tours"),
|
||||
object : TypeToken<List<String>>() {}.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<Waypoint> = runCatching {
|
||||
gson.fromJson<List<Waypoint>>(
|
||||
payload.get("waypoints"),
|
||||
object : TypeToken<List<Waypoint>>() {}.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<String, TourPlaybackDefaults> = runCatching {
|
||||
gson.fromJson<Map<String, TourPlaybackDefaults>>(
|
||||
payload.get("tourDefaults"),
|
||||
object : TypeToken<Map<String, TourPlaybackDefaults>>() {}.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<String, TourRoute> = runCatching {
|
||||
gson.fromJson<Map<String, TourRoute>>(
|
||||
payload.get("tourRoutes"),
|
||||
object : TypeToken<Map<String, TourRoute>>() {}.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/<idx>_<tour>.jpg" (v2.5.31+) oder
|
||||
// "media/covers/<tour>.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<String, TourCover> = runCatching {
|
||||
gson.fromJson<Map<String, TourCover>>(
|
||||
payload.get("tourCovers"),
|
||||
object : TypeToken<Map<String, TourCover>>() {}.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<String, List<GpsTrackPoint>>()
|
||||
var tracksImported = 0
|
||||
if (selection.trackDrafts && draftsJson != null) {
|
||||
val obj = runCatching {
|
||||
JsonParser.parseString(draftsJson).asJsonObject
|
||||
}.getOrNull()
|
||||
val map: Map<String, List<GpsTrackPoint>> = if (obj != null) {
|
||||
runCatching {
|
||||
gson.fromJson<Map<String, List<GpsTrackPoint>>>(
|
||||
obj.get("tracks"),
|
||||
object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.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<String, TourAudioSettings>()
|
||||
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>): 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<String, String>,
|
||||
mediaByLeafName: Map<String, List<String>>,
|
||||
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 { "_" }
|
||||
}
|
||||
}
|
||||
@@ -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('"')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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<String, Boolean> = emptyMap(),
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
enum class State { NONE, VALID, INVALID, EXPIRED }
|
||||
}
|
||||
@@ -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<String, Boolean> {
|
||||
if (perms == null) return emptyMap()
|
||||
val out = linkedMapOf<String, Boolean>()
|
||||
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, Boolean>): 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)
|
||||
}
|
||||
@@ -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/<pkg>/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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, List<GpsTrackPoint>>> = 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<Map<String, TourRoute>> = 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<String, TourRoute>) = store.mergeTourRoutes(routes)
|
||||
|
||||
/** v2.5.5 — Reaktiver Flow der persistierten Tour-Cover (Map tourName → TourCover). */
|
||||
val tourCovers: Flow<Map<String, TourCover>> = 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<String, TourCover>) = 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<List<TrackDraft>> = 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)
|
||||
}
|
||||
|
||||
@@ -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<MusicPlaybackState> = _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<PlaylistItem> = emptyList()
|
||||
private var loadedStreamEntries: List<StreamEntry> = 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<String> {
|
||||
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<String> {
|
||||
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<StreamEntry>): List<MediaItem> {
|
||||
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<PlaylistItem>): List<MediaItem> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Source>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<PlaylistItem> = 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<MusicPlaybackState> = 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<MusicPlaybackState> = _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<PlaylistItem>): List<MediaItem> {
|
||||
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<MediaItem> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <filesDir>/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<Boolean> = _pttActive.asStateFlow()
|
||||
|
||||
/** Aktuell gespeicherte Routing-Einstellungen. */
|
||||
/** true wenn aktuell eine Aufnahme läuft. */
|
||||
private val _recordingActive = MutableStateFlow(false)
|
||||
val recordingActive: StateFlow<Boolean> = _recordingActive.asStateFlow()
|
||||
|
||||
/** Aktuell gespeicherte Routing-/PTT-Einstellungen. */
|
||||
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
|
||||
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
|
||||
|
||||
/** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */
|
||||
/** Verfügbare Eingabegeräte. */
|
||||
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
|
||||
|
||||
/** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */
|
||||
/** Verfügbare Ausgabegeräte. */
|
||||
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
|
||||
|
||||
@@ -75,10 +102,27 @@ class LivePttManager private constructor(private val appContext: Context) {
|
||||
private val _statusMessage = MutableStateFlow<String?>(null)
|
||||
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
|
||||
|
||||
/**
|
||||
* Reaktiver Flow der gespeicherten Aufnahmen. Wird von der UI für Bibliothek
|
||||
* und Post-Aufnahme-Dialog beobachtet.
|
||||
*/
|
||||
val recordings: Flow<List<PttRecording>> = recordingStore.recordings
|
||||
|
||||
/**
|
||||
* Zuletzt fertig gestellte Aufnahme (für Post-Aufnahme-Dialog).
|
||||
* Wird von der UI mit [clearLastRecording] zurückgesetzt.
|
||||
*/
|
||||
private val _lastRecording = MutableStateFlow<PttRecording?>(null)
|
||||
val lastRecording: StateFlow<PttRecording?> = _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<Double?, Double?> {
|
||||
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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 <filesDir>/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 <R> MediaMetadataRetriever.use(block: (MediaMetadataRetriever) -> R): R {
|
||||
return try { block(this) } finally { runCatching { release() } }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Source, Registration>()
|
||||
|
||||
/** Quellen, die bei der laufenden Unterbrechung tatsächlich pausiert wurden. */
|
||||
private val pausedDuringInterruption = mutableSetOf<Source>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<de.waypointaudio.data.PlaylistItem>,
|
||||
alreadySelectedUris: Set<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (List<de.waypointaudio.data.PlaylistItem>) -> 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<String>() }
|
||||
|
||||
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
|
||||
|
||||
@@ -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<TrackPreview>,
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<String>()
|
||||
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<Color, Color> = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(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()
|
||||
|
||||
@@ -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<MapBaseStyle?>(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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<Pair<Double, Double>?>(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<Waypoint?>(null) }
|
||||
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
|
||||
@@ -167,6 +185,15 @@ fun MapScreen(
|
||||
// Bestätigungsdialog für das Löschen eines Wegpunkts
|
||||
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(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<GeoPoint?>(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<Polygon>().toSet())
|
||||
val waypointMarkers = overlays.filterIsInstance<Marker>()
|
||||
.filter { it.title != "Mein Standort" }
|
||||
overlays.removeAll(waypointMarkers.toSet())
|
||||
overlays.removeAll(overlays.filterIsInstance<Polyline>().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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<List<NominatimResult>>(emptyList()) }
|
||||
var hasSearched by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(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
|
||||
}
|
||||
@@ -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<PttRecording?>(null) }
|
||||
|
||||
// Lokaler Preview-Player. Wird beim Schließen des Dialogs zwingend freigegeben.
|
||||
val previewPlayer = remember { RecordingPreviewPlayer() }
|
||||
var playingId by remember { mutableStateOf<String?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RouteStop> = 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<RouteStop> {
|
||||
val out = mutableListOf<RouteStop>()
|
||||
start?.let { out += it }
|
||||
out += via
|
||||
destination?.let { out += it }
|
||||
return out
|
||||
}
|
||||
|
||||
/** Lat/Lon-Paare für OSRM (Start..Via..Ziel). */
|
||||
fun toOsrmPoints(): List<Pair<Double, Double>> =
|
||||
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 }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<List<OverpassPoi>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var errorMsg by remember { mutableStateOf<String?>(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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<Int?>(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<GeoPoint?>(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<MapView?>(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<String>,
|
||||
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<Polyline>().toSet())
|
||||
overlays.removeAll(overlays.filterIsInstance<Marker>().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<GpsTrackPoint>,
|
||||
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<GpsTrackPoint>): 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<GpsTrackPoint>): String {
|
||||
val d = draftDistanceMeters(points)
|
||||
return if (d >= 1000) "%.1f km".format(d / 1000.0) else "${d.toInt()} m"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<AudioClip>().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<Int?>(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<String>,
|
||||
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“)")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = 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<String, List<ExplorerPoiResult>>()
|
||||
|
||||
/** 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<PoiCategory>,
|
||||
onRadiusClamped: () -> Unit = {},
|
||||
): Map<PoiCategory, List<ExplorerPoiResult>> = 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<PoiCategory, List<ExplorerPoiResult>>()
|
||||
|
||||
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<PoiCategory, List<ExplorerPoiResult>>): 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<ExplorerPoiResult> {
|
||||
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<Pair<String, String>> = 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<ExplorerPoiResult> {
|
||||
val root = JSONObject(json)
|
||||
val elements = root.optJSONArray("elements") ?: return emptyList()
|
||||
val out = ArrayList<ExplorerPoiResult>(elements.length())
|
||||
val seen = HashSet<Long>() // 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<String, String>, 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<String, String>, 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))
|
||||
}
|
||||
}
|
||||
@@ -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<NominatimResult> = 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<NominatimResult> {
|
||||
val arr = JSONArray(json)
|
||||
val out = ArrayList<NominatimResult>(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
|
||||
}
|
||||
}
|
||||
@@ -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<Pair<Double, Double>>,
|
||||
/** 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/<profil>` (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/<profil>` 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<Pair<Double, Double>>,
|
||||
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<Pair<Double, Double>>(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))
|
||||
}
|
||||
}
|
||||
@@ -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<OverpassPoi> = 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<OverpassPoi> {
|
||||
val root = JSONObject(json)
|
||||
val elements = root.optJSONArray("elements") ?: return emptyList()
|
||||
val out = ArrayList<OverpassPoi>(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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
GPS2Audio splash icon (v2.5.8)
|
||||
Wird vom Android Splash Screen (androidx.core.splashscreen) zentriert
|
||||
auf dem Splash-Hintergrund (navy) gezeichnet. Reine Vektorgrafik –
|
||||
keine externen Bilder. Form: Map-Pin mit Sound-Wellen, identisch zur
|
||||
Launcher-Identität, aber dezent angepasst (etwas mehr Whitespace).
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="192dp"
|
||||
android:height="192dp"
|
||||
android:viewportWidth="192"
|
||||
android:viewportHeight="192">
|
||||
|
||||
<!-- Map-pin teardrop body – teal -->
|
||||
<path
|
||||
android:fillColor="#4DB6AC"
|
||||
android:pathData="
|
||||
M86,40
|
||||
C71,40 59,52 59,67
|
||||
C59,88 86,124 86,124
|
||||
C86,124 113,88 113,67
|
||||
C113,52 101,40 86,40Z" />
|
||||
|
||||
<!-- Pin inner cut-out circle – navy -->
|
||||
<path
|
||||
android:fillColor="#0D1B2A"
|
||||
android:pathData="M86,57 a10,10 0 1 0 0.001 0Z" />
|
||||
|
||||
<!-- Sound-wave arc 1 – inner, white -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="3.5"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M125,82 Q132,96 125,110" />
|
||||
|
||||
<!-- Sound-wave arc 2 – middle, white -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="3.5"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M133,75 Q145,96 133,117" />
|
||||
|
||||
<!-- Sound-wave arc 3 – outer, light teal -->
|
||||
<path
|
||||
android:strokeColor="#80CBC4"
|
||||
android:strokeWidth="3"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M141,68 Q157,96 141,124" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ544rSBSWPjtB/0erDgpIoZhfgTg
|
||||
7lk7rPb2trDPkpTGVFL9hMH6saaXiLn8KfRi26CfLufJGOMaX9DWYHo0wQ==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="splash_background">#0D1B2A</color>
|
||||
</resources>
|
||||
@@ -17,6 +17,12 @@
|
||||
<string name="track_recording_notification_text">GPS-Track wird aufgezeichnet%1$s</string>
|
||||
<string name="track_recording_permission_missing">Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert.</string>
|
||||
|
||||
<!-- Notification (Atmo/Begleitmusik Hintergrund-Wiedergabe) -->
|
||||
<string name="atmo_notification_channel_name">Begleitmusik (Atmo)</string>
|
||||
<string name="atmo_notification_channel_description">Hält die Begleitmusik im Hintergrund aktiv, wenn die App minimiert ist.</string>
|
||||
<string name="atmo_notification_default_title">Begleitmusik läuft</string>
|
||||
<string name="atmo_notification_default_source">GPS2Audio</string>
|
||||
|
||||
<!-- UI strings -->
|
||||
<string name="add_waypoint">Wegpunkt hinzufügen</string>
|
||||
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
|
||||
@@ -50,12 +56,58 @@
|
||||
<string name="map_view">Karte</string>
|
||||
<string name="map_attribution">Kartendaten © OpenStreetMap-Mitwirkende</string>
|
||||
|
||||
<!-- OpenStreetMap-Attribution (Pflicht-Hinweis auf jeder Karte) -->
|
||||
<string name="osm_attribution_short">© OpenStreetMap-Mitwirkende</string>
|
||||
<string name="osm_attribution_full">Kartendaten © OpenStreetMap-Mitwirkende · ODbL</string>
|
||||
|
||||
<!-- Track-Editor / Draft-Archiv -->
|
||||
<string name="menu_track_editor">Track-Editor</string>
|
||||
<string name="draft_list_title">Track-Editor</string>
|
||||
<string name="draft_list_empty">Noch keine Aufzeichnungen.\nNimm einen Track auf der Karte auf — er wird hier automatisch archiviert.</string>
|
||||
<string name="draft_list_subtitle">Tour: %1$s · %2$s</string>
|
||||
<string name="draft_list_points_dist">%1$d Punkte · %2$s</string>
|
||||
<string name="draft_list_marked_count">%1$d markiert</string>
|
||||
<string name="draft_unnamed">Unbenannter Track</string>
|
||||
<string name="draft_editor_subtitle">%1$d Punkte · %2$d markiert</string>
|
||||
<string name="draft_edit_meta_title">Track umbenennen</string>
|
||||
<string name="draft_name_label">Name</string>
|
||||
<string name="draft_notes_label">Notiz (optional)</string>
|
||||
<string name="draft_delete">Aufzeichnung löschen</string>
|
||||
<string name="draft_delete_confirm_title">Aufzeichnung löschen?</string>
|
||||
<string name="draft_delete_confirm_msg">„%1$s" wird unwiderruflich gelöscht.</string>
|
||||
<string name="draft_point_selected">Punkt %1$d / %2$d</string>
|
||||
<string name="draft_point_mark">Als Wegpunkt markieren</string>
|
||||
<string name="draft_point_unmark">Markierung entfernen</string>
|
||||
<string name="draft_point_move_hint">Karte lange drücken, um den ausgewählten Punkt zu verschieben.</string>
|
||||
<string name="draft_editor_help">Tippe einen Streckenpunkt, um ihn als Wegpunkt zu markieren. Lang drücken wählt ihn zum Verschieben oder Löschen aus.</string>
|
||||
<string name="draft_mark_all">Alle markieren</string>
|
||||
<string name="draft_unmark_all">Markierungen löschen</string>
|
||||
<string name="draft_locked_while_recording">Während der Aufzeichnung gesperrt — bitte zuerst stoppen.</string>
|
||||
<string name="draft_import_button">In Tour übernehmen</string>
|
||||
<string name="draft_import_title">Markierte Punkte als Wegpunkte importieren</string>
|
||||
<string name="draft_import_hint_marked">%1$d von %2$d markierte Punkte werden importiert. Nicht markierte bleiben im Draft.</string>
|
||||
<string name="draft_import_hint_none">Keine Wegpunkte markiert — tippe Streckenpunkte, um sie zu markieren.</string>
|
||||
<string name="draft_import_target_label">Zieltour</string>
|
||||
<string name="draft_import_confirm">%1$d übernehmen</string>
|
||||
<string name="draft_import_disabled_hint">Keine Wegpunkte markiert</string>
|
||||
|
||||
<!-- Editor: Ortssuche (Nominatim) -->
|
||||
<string name="search_place_action">Ort suchen</string>
|
||||
<string name="search_place_title">Ort suchen</string>
|
||||
<string name="search_place_query_label">Suchbegriff</string>
|
||||
<string name="search_place_button">Suchen</string>
|
||||
<string name="search_place_loading">Suche läuft …</string>
|
||||
<string name="search_place_no_results">Keine Treffer gefunden.</string>
|
||||
<string name="search_place_error">Suche fehlgeschlagen — bitte Internetverbindung prüfen.</string>
|
||||
<string name="search_place_attribution">Suche: OpenStreetMap/Nominatim</string>
|
||||
<string name="search_place_close">Schließen</string>
|
||||
|
||||
<!-- Map: Als Wegpunkt übernehmen -->
|
||||
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
|
||||
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
|
||||
|
||||
<!-- Map Editor -->
|
||||
<string name="map_editor_hint">Marker antippen · Karte lange drücken</string>
|
||||
<string name="map_editor_hint">Streckenpunkt antippen zum Auswählen (rot) · Karte lange drücken für neuen Punkt</string>
|
||||
<string name="map_waypoint_edit">Bearbeiten</string>
|
||||
<string name="map_waypoint_delete">Löschen</string>
|
||||
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
|
||||
@@ -84,28 +136,246 @@
|
||||
<string name="menu_about">Über diese App</string>
|
||||
<string name="about_title">Über diese App</string>
|
||||
<string name="about_app_name">GPS2Audio</string>
|
||||
<string name="about_description">Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden.</string>
|
||||
<string name="about_description">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.</string>
|
||||
<string name="about_version_label">Version</string>
|
||||
<string name="about_version">1.0</string>
|
||||
<string name="about_version">1.5.5-tabrow-fix</string>
|
||||
<string name="about_developer_label">Entwickler</string>
|
||||
<string name="about_developer">Marcel Mayer</string>
|
||||
<string name="about_email_label">E-Mail</string>
|
||||
<string name="about_email">marcel.mayer@nesohub.org</string>
|
||||
<string name="about_terms_label">Nutzungsbedingungen</string>
|
||||
<string name="about_terms_text">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.</string>
|
||||
<string name="about_license_label">Lizenz</string>
|
||||
<string name="about_license_name">Apache License 2.0</string>
|
||||
<string name="about_license_summary">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.</string>
|
||||
<string name="about_license_name">Apache License 2.0 + Commons Clause</string>
|
||||
<string name="about_license_summary">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.</string>
|
||||
<string name="about_license_url">https://www.apache.org/licenses/LICENSE-2.0</string>
|
||||
<string name="about_license_disclaimer">Dieser Hinweis ersetzt keine Rechtsberatung. Maßgeblich ist im Zweifel der Originaltext der Apache License 2.0.</string>
|
||||
<string name="about_close">Schließen</string>
|
||||
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
|
||||
|
||||
<!-- Matrix contact -->
|
||||
<string name="about_matrix_label">Matrix-Kontakt</string>
|
||||
<string name="about_matrix_description">Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren.</string>
|
||||
<string name="about_matrix_id">@neso:nesohub.org</string>
|
||||
<string name="about_matrix_url">https://matrix.to/#/@neso:nesohub.org</string>
|
||||
<string name="about_matrix_open_button">Matrix-Chat öffnen</string>
|
||||
<string name="about_matrix_open_cd">Matrix-Chat mit @neso:nesohub.org öffnen</string>
|
||||
<string name="about_matrix_qr_content_description">QR-Code für Matrix-Kontakt @neso:nesohub.org</string>
|
||||
<!-- v2.5.17 — Lizenz & Nutzung / GPS2Audio Pro
|
||||
Die bisherige Spenden-/„Entwicklung unterstützen“-Sektion wurde
|
||||
entfernt und durch einen professionellen Lizenz-Hinweis ersetzt.
|
||||
Der PayPal-Link bleibt erhalten, dient aber nicht mehr als
|
||||
Spenden-Aufruf, sondern als Kanal für eine Pro-Lizenz-Anfrage. -->
|
||||
<string name="about_pro_section_label">Lizenz & Nutzung</string>
|
||||
<string name="about_pro_title">GPS2Audio Profi</string>
|
||||
<string name="about_pro_tagline">Die GPS2Audio Profi-Lizenz ist eine einjährig gültige Lizenz für professionelle Anwender, Reiseleiter, Institutionen und Anbieter geführter Touren.</string>
|
||||
<string name="about_pro_tagline2">Sie ermöglicht professionelle Nutzung und unterstützt die kontinuierliche Weiterentwicklung der App.</string>
|
||||
<string name="about_pro_renewal_note">Die Lizenz läuft nicht automatisch weiter und kann bei Bedarf aktiv erneuert werden. Laufzeit und Konditionen werden in der Lizenzdatei hinterlegt.</string>
|
||||
<string name="about_pro_features_label">Für professionelle Nutzung vorgesehen:</string>
|
||||
<string name="about_pro_features_list">• Multi-Clip-Waypoints\n• Live/PTT-Funktion\n• Tour-Zähler</string>
|
||||
<string name="about_pro_required_intro">Typische Einsatzbereiche:</string>
|
||||
<string name="about_pro_required_line1">• Kommerzielle Nutzung und bezahlte Touren</string>
|
||||
<string name="about_pro_required_line2">• Institutionelle Nutzung (Behörden, Tourismusverbände, Reiseveranstalter, Bildungsträger)</string>
|
||||
<string name="about_pro_required_line3">• Kundenprojekte und Whitelabel-/Auftragsarbeiten</string>
|
||||
<string name="about_pro_required_line4">• Professionelle Reiseleitung und geführte Touren</string>
|
||||
<string name="about_pro_private_note">Die private und interne (nicht-kommerzielle) Nutzung bleibt gemäß den Lizenzbedingungen (Apache 2.0 + Commons Clause) möglich.</string>
|
||||
<string name="about_pro_manual_note">Anfragen werden zeitnah bearbeitet. Bitte Lizenznehmer-Name, Firma/Institution und Verwendungszweck angeben.</string>
|
||||
<string name="about_pro_request_button">Profi-Lizenz per E-Mail anfragen</string>
|
||||
<string name="about_pro_request_open_cd">E-Mail-App für Profi-Lizenz-Anfrage öffnen</string>
|
||||
<string name="about_pro_email_recipient">marcel.mayer@nesohub.org</string>
|
||||
<string name="about_pro_email_subject">GPS2Audio Profi-Lizenz anfragen</string>
|
||||
<string name="about_pro_email_body_template">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</string>
|
||||
<string name="about_pro_email_no_app">Keine E-Mail-App gefunden. Bitte senden Sie eine E-Mail an marcel.mayer@nesohub.org mit Ihrem Namen, Ihrer Firma und dem Verwendungszweck.</string>
|
||||
<string name="about_pro_contact_hint">Empfänger: marcel.mayer@nesohub.org</string>
|
||||
|
||||
<!-- v2.5.17 — Lizenzstatus-Anzeige im About-Dialog.
|
||||
Diese Werte sind Platzhalter und werden in einer späteren Version
|
||||
durch die echte Offline-Lizenzprüfung gefüllt. Aktuell ist der
|
||||
Status statisch „Keine Lizenz importiert“, damit nichts crasht und
|
||||
keine aggressiven Feature-Gates aktiv werden. -->
|
||||
<string name="about_license_status_label">Lizenzstatus</string>
|
||||
<string name="about_license_status_state_label">Status</string>
|
||||
<string name="about_license_status_state_value">Keine Lizenz importiert</string>
|
||||
<string name="about_license_status_holder_label">Lizenznehmer</string>
|
||||
<string name="about_license_status_holder_value">-</string>
|
||||
<string name="about_license_status_validity_label">Gültigkeit</string>
|
||||
<string name="about_license_status_validity_value">-</string>
|
||||
<string name="about_license_status_features_label">Freigeschaltete Funktionen</string>
|
||||
<string name="about_license_status_features_value">Basisfunktionen</string>
|
||||
<string name="about_license_status_hint">Eine importierte Pro-Lizenzdatei wird hier künftig angezeigt. Bis dahin sind alle bestehenden Funktionen ohne Einschränkung verfügbar.</string>
|
||||
|
||||
<!-- v2.5.20 — Lizenz Import/Export/Entfernen. Diese Buttons aktivieren
|
||||
keine Feature-Sperren; sie dienen nur Anzeige und Vorbereitung. -->
|
||||
<string name="about_license_actions_label">Lizenzaktionen</string>
|
||||
<string name="about_license_action_import">Lizenz importieren</string>
|
||||
<string name="about_license_action_export">Lizenz exportieren</string>
|
||||
<string name="about_license_action_remove">Lizenz entfernen</string>
|
||||
<string name="about_license_action_remove_confirm_title">Lizenz entfernen?</string>
|
||||
<string name="about_license_action_remove_confirm_text">Die importierte Lizenz wird lokal gelöscht. Die App fällt auf „Keine Lizenz importiert" zurück. Bestehende Touren und Mediendateien bleiben unverändert.</string>
|
||||
<string name="about_license_action_remove_confirm_ok">Entfernen</string>
|
||||
<string name="about_license_action_remove_confirm_cancel">Abbrechen</string>
|
||||
<string name="about_license_action_remove_done">Lizenz entfernt</string>
|
||||
<string name="about_license_action_remove_fail">Lizenzdatei konnte nicht entfernt werden</string>
|
||||
<string name="about_license_import_success">Lizenz importiert: %1$s</string>
|
||||
<string name="about_license_import_fail">Lizenz ungültig: %1$s</string>
|
||||
<string name="about_license_import_fail_invalid">Ungültige Lizenzdatei: %1$s</string>
|
||||
<string name="about_license_import_read_fail">Lizenzdatei konnte nicht gelesen werden</string>
|
||||
<string name="about_license_import_err_open">Datei konnte nicht geöffnet werden. Bitte erneut versuchen.</string>
|
||||
<string name="about_license_import_err_empty">Datei ist leer oder enthält keine Daten.</string>
|
||||
<string name="about_license_import_err_permission">Keine Leseberechtigung für diese Datei. Bitte Datei-Manager verwenden.</string>
|
||||
<string name="about_license_import_err_io">Lesefehler beim Zugriff auf die Datei. Bitte erneut versuchen.</string>
|
||||
<string name="about_license_import_err_no_app">Kein Datei-Manager verfügbar. Bitte einen Datei-Manager installieren.</string>
|
||||
<string name="about_license_export_filename_default">gps2audio.license.json</string>
|
||||
<string name="about_license_export_success">Lizenz exportiert</string>
|
||||
<string name="about_license_export_fail">Lizenz konnte nicht exportiert werden</string>
|
||||
<string name="about_license_export_nothing">Keine importierte Lizenz vorhanden</string>
|
||||
<string name="about_license_type_label">Lizenztyp</string>
|
||||
<string name="about_license_id_label">Lizenz-ID</string>
|
||||
<string name="about_license_binding_label">Bindung</string>
|
||||
<string name="about_license_permissions_label">Berechtigungen</string>
|
||||
<string name="about_license_no_permissions">-</string>
|
||||
<!-- v2.5.21 — Geräte-ID -->
|
||||
<string name="about_device_id_label">Lizenz-Geräte-ID</string>
|
||||
<string name="about_device_id_hint">Für optionale gerätegebundene Lizenzen. Nicht im normalen Backup enthalten. Wenn App-Daten gelöscht werden, ändert sich die ID.</string>
|
||||
<string name="about_device_id_field_label">Geräte-ID</string>
|
||||
<string name="about_device_id_copy_label">GPS2Audio Geräte-ID</string>
|
||||
<string name="about_device_id_copy_button">ID kopieren</string>
|
||||
<string name="about_device_id_copied">Geräte-ID in Zwischenablage kopiert</string>
|
||||
|
||||
<!-- Version notice popup (shown once per new versionCode) -->
|
||||
<string name="version_notice_title">GPS2Audio aktualisiert</string>
|
||||
<string name="version_notice_heading">Neue Version: %1$s</string>
|
||||
<string name="version_notice_body_v2512">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.</string>
|
||||
<string name="version_notice_body_v2523">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.</string>
|
||||
<string name="version_notice_body_v2521">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.</string>
|
||||
<string name="version_notice_body_v2519">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.</string>
|
||||
<string name="version_notice_body_v242">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.</string>
|
||||
<string name="version_notice_body">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.</string>
|
||||
|
||||
<!-- v2.2.1 — Unified Map module -->
|
||||
<string name="map_mode_explorer">Explorer</string>
|
||||
<string name="map_mode_editor">Editor</string>
|
||||
<string name="map_mode_switch_to_editor">Zum Editor wechseln</string>
|
||||
<string name="map_mode_switch_to_explorer">Zum Explorer wechseln</string>
|
||||
<string name="map_unified_title">Karte</string>
|
||||
<string name="map_layers_title">Karten-Ebenen</string>
|
||||
<string name="map_layers_open">Ebenen / 3D</string>
|
||||
<string name="map_layers_follow">Standort folgen</string>
|
||||
<string name="map_layers_markers">Wegpunkt-Marker</string>
|
||||
<string name="map_layers_radius">Audio-Radius / Reichweite</string>
|
||||
<string name="map_layers_track">GPS-Track / Draft-Linien</string>
|
||||
<string name="map_layers_3d_label">3D-Modus</string>
|
||||
<string name="map_layers_3d_prepared">3D in Vorbereitung — MapLibre ist in v2.2.2 als Build-Abhängigkeit verdrahtet, aktives Rendering läuft aber weiterhin auf osmdroid (2D-Raster).</string>
|
||||
<string name="map_layers_3d_unavailable">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.</string>
|
||||
<string name="map_layers_close">Schließen</string>
|
||||
<!-- v2.3.0 — MapLibre/Overlays -->
|
||||
<string name="map_layers_base_section">Basis-Karte</string>
|
||||
<string name="map_layers_overlays_section">Ebenen</string>
|
||||
<!-- v2.5.37 — Explorer Map Styles (OpenFreeMap Vektor-Stile) -->
|
||||
<string name="map_layers_base_2d">Modern</string>
|
||||
<string name="map_layers_base_dark">Dunkel</string>
|
||||
<string name="map_layers_base_outdoor">Outdoor</string>
|
||||
<string name="map_layers_base_city3d">3D Stadt</string>
|
||||
<string name="map_layers_base_classic">Klassisch</string>
|
||||
<!-- Legacy (nicht mehr im UI, aber Strings bleiben für Kompatibilität) -->
|
||||
<string name="map_layers_base_topo">Outdoor</string>
|
||||
<string name="map_layers_base_terrain">3D Stadt</string>
|
||||
<string name="map_layers_base_satellite">Satellit</string>
|
||||
<string name="map_layers_base_3d">3D / Perspektive</string>
|
||||
<!-- v2.5.37 — Stil-Infotexte (OpenFreeMap Vektor-Stile) -->
|
||||
<string name="map_layers_modern_info">Modern (OpenFreeMap Positron): Heller, reduzierter Vektorstil. Keine API-Key-Pflicht, keine Nutzungsgrenzen. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL).</string>
|
||||
<string name="map_layers_dark_info">Dunkel (OpenFreeMap Dark Matter): Echter dunkler Vektorstil. Keine API-Key-Pflicht. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL).</string>
|
||||
<string name="map_layers_dark_note">Dunkler Vektorstil (OpenFreeMap Dark) — kein API-Key, kostenlos.</string>
|
||||
<string name="map_layers_outdoor_info">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).</string>
|
||||
<string name="map_layers_outdoor_note">Outdoor-Vektorstil (OpenFreeMap Liberty) — Gelände und Wegenetze.</string>
|
||||
<string name="map_layers_city3d_info">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.</string>
|
||||
<string name="map_layers_city3d_note">3D-Stadtansicht mit Gebäude-Extrusion — kein API-Key, kostenlos. Bei fehlendem building-Layer: 2D-Fallback.</string>
|
||||
<string name="map_layers_classic_info">Klassisch (OSM-Raster): Standard-OpenStreetMap-Rastertiles (tile.openstreetmap.org). Attribution: © OpenStreetMap-Mitwirkende (ODbL). Kein Vektor, kein 3D.</string>
|
||||
<!-- Legacy-Strings (bleiben für Rückwärtskompatibilität) -->
|
||||
<string name="map_layers_topo_default_attribution">Karte: © OpenFreeMap Liberty, Daten: © OpenStreetMap-Mitwirkende</string>
|
||||
<string name="map_layers_topo_custom_label">Outdoor-Stil-Quelle</string>
|
||||
<string name="map_layers_topo_custom_hint">OpenFreeMap Liberty wird automatisch verwendet.</string>
|
||||
<string name="map_layers_topo_not_configured">Outdoor-Quelle nicht konfiguriert</string>
|
||||
<string name="map_layers_topo_info">Outdoor-Stil (OpenFreeMap Liberty): Kostenlos, kein API-Key. Attribution sichtbar.</string>
|
||||
<string name="map_layers_terrain_custom_label">3D-Stadt Pitch-Einstellung</string>
|
||||
<string name="map_layers_terrain_custom_hint">Neigungswinkel für den 3D-Stadtmodus.</string>
|
||||
<string name="map_layers_terrain_not_configured">3D-Stadtansicht aktiv (Positron + Gebäude-Extrusion).</string>
|
||||
<string name="map_layers_terrain_info">3D Stadt: OpenFreeMap Positron mit Gebäude-Extrusion. Camera-Pitch einstellbar. Kein API-Key benötigt.</string>
|
||||
<string name="map_layers_terrain_pitch_label">Neigung (Pitch)</string>
|
||||
<string name="map_layers_audio_length">Audio-Länge je Wegpunkt</string>
|
||||
<string name="map_layers_satellite_custom_label">Eigene Satelliten-Tile-URL</string>
|
||||
<string name="map_layers_satellite_custom_hint">Optional. XYZ-Format mit {z}/{x}/{y}. Bei leerem Feld wird die Standard-Quelle (ESRI World Imagery) verwendet – siehe Quellenhinweis auf der Karte.</string>
|
||||
<string name="map_layers_satellite_info_button">Info / Quelle</string>
|
||||
<string name="map_layers_satellite_info">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.</string>
|
||||
<string name="map_layers_3d_preview_note">Perspektive aktiv: geneigte Kameraansicht als Vorschau. Echtes 3D-Terrain (DEM) folgt mit dem MapLibre-Native-Renderer.</string>
|
||||
<string name="map_layers_routing_label">Routenplaner</string>
|
||||
<string name="map_layers_routing_prepared">Folgt — kein externer Routingdienst aktiv. Self-hosted OSRM/Valhalla/GraphHopper geplant.</string>
|
||||
<string name="map_provider_maplibre_demo">MapLibre Demotiles (frei)</string>
|
||||
<string name="routing_dialog_title">Routenplaner folgt</string>
|
||||
<string name="routing_dialog_body">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.</string>
|
||||
<string name="map_attribution_satellite_esri">Tiles © Esri</string>
|
||||
<string name="map_attribution_satellite_custom">Satellit: eigene Tile-Quelle</string>
|
||||
<string name="explorer_waypoint_audio_duration">Audio-Dauer: %1$s</string>
|
||||
|
||||
<!-- Explorer (v2.2.0) -->
|
||||
<string name="explorer_title">Explorer</string>
|
||||
<string name="explorer_open">Explorer öffnen</string>
|
||||
<string name="explorer_subtitle">Karte · Standort · Wegpunkte (read-only)</string>
|
||||
<string name="explorer_hint_no_location">Standort nicht verfügbar — Berechtigung oder GPS prüfen.</string>
|
||||
<string name="explorer_hint_idle">Tippe „Mein Standort", um die Karte auf deine Position zu zentrieren. Wegpunkte der aktuellen Tour werden angezeigt.</string>
|
||||
<string name="explorer_follow_on">Nachführung an</string>
|
||||
<string name="explorer_follow_off">Nachführung aus</string>
|
||||
<string name="explorer_open_in_editor">In Karteneditor öffnen</string>
|
||||
<string name="explorer_save_as_tour">Auswahl übernehmen</string>
|
||||
<string name="explorer_save_as_tour_hint">Übernahme folgt im nächsten Schritt.</string>
|
||||
<string name="explorer_no_ptt_atmo_hint">Explorer ist ein reiner Vorerkundungs-Modus — keine PTT, keine Atmo, keine Aufzeichnung.</string>
|
||||
<string name="explorer_waypoints_count">%1$d Wegpunkte sichtbar</string>
|
||||
<string name="explorer_my_location">Mein Standort</string>
|
||||
<string name="explorer_accuracy_meters">Genauigkeit: %1$d m</string>
|
||||
|
||||
<!-- Explorer v2.2.2 — Editier-Aktionen direkt im Explorer. -->
|
||||
<string name="explorer_subtitle_v222">Karte · Standort · Wegpunkte bearbeiten</string>
|
||||
<string name="explorer_add_waypoint">Wegpunkt</string>
|
||||
<string name="explorer_action_edit">Wegpunkt bearbeiten</string>
|
||||
<string name="explorer_action_move_tap">Position per Tap setzen</string>
|
||||
<string name="explorer_action_move_to_center">Auf Kartenmitte verschieben</string>
|
||||
<string name="explorer_action_tap_place">Neuer Wegpunkt per Tap</string>
|
||||
<string name="explorer_tap_place_active">Tippe auf die Karte, um einen neuen Wegpunkt zu setzen.</string>
|
||||
<string name="explorer_tap_move_active">Tippe die neue Position an — der gewählte Wegpunkt wird dorthin verschoben.</string>
|
||||
<string name="explorer_cancel_tap">Tap-Modus abbrechen</string>
|
||||
<string name="menu_legacy_map_editor">Karteneditor (Track / Drafts)</string>
|
||||
|
||||
<!-- v2.2.3 — Explorer als zentraler Arbeitsbereich: GPS-Recording, Drafts, Storyboard -->
|
||||
<string name="explorer_subtitle_v223">Karte · Standort · Wegpunkte · GPS-Aufzeichnung · Drafts · Storyboard</string>
|
||||
<string name="explorer_subtitle_recording">Aufzeichnung läuft · %1$d Punkte</string>
|
||||
<string name="explorer_subtitle_draft_active">Draft aktiv: %1$s · %2$d Punkte · %3$d markiert</string>
|
||||
<string name="explorer_drafts_open">Aufgenommene Tracks (Drafts)</string>
|
||||
<string name="explorer_drafts_title">Aufgenommene Tracks</string>
|
||||
<string name="explorer_drafts_hint">Wähle einen Draft, um seine Punkte direkt im Explorer zu sehen und zu bearbeiten. Detaillierte Bearbeitung im Vollbild-Editor weiterhin verfügbar.</string>
|
||||
<string name="explorer_storyboard_open">Storyboard / Routenplanung</string>
|
||||
<string name="explorer_storyboard_title">Storyboard / Routenplanung</string>
|
||||
<string name="explorer_storyboard_hint">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.</string>
|
||||
<string name="explorer_storyboard_target_tour">Zieltour (Name)</string>
|
||||
<string name="explorer_storyboard_empty">Keine Wegpunkte in der aktuellen Tour. Lege erst welche im Explorer an.</string>
|
||||
<string name="explorer_storyboard_up">Nach oben</string>
|
||||
<string name="explorer_storyboard_down">Nach unten</string>
|
||||
<string name="explorer_storyboard_save">Reihenfolge übernehmen</string>
|
||||
<string name="explorer_storyboard_no_routing_hint">Hinweis: Es wird keine externe Routing-Engine verwendet (keine API, keine Kosten). Das Storyboard ist eine geordnete Wegpunkt-Liste — Entwurfsstatus.</string>
|
||||
<string name="explorer_track_point_title">Trackpunkt %1$d</string>
|
||||
<string name="explorer_track_point_no_draft_hint">Aktiviere zuerst einen Draft im Drafts-Sheet, um Trackpunkte zu bearbeiten.</string>
|
||||
<string name="explorer_tap_move_track_active">Tippe die neue Position an — der gewählte Trackpunkt wird dorthin verschoben.</string>
|
||||
|
||||
<!-- v2.2.2 MapLibre / Layer-Hinweise -->
|
||||
<string name="map_engine_status_osm">Karten-Engine: osmdroid (OSM-Raster)</string>
|
||||
<string name="map_engine_status_maplibre_prepared">Karten-Engine: MapLibre-Anbindung vorbereitet · Aktiv: osmdroid</string>
|
||||
|
||||
<string name="version_notice_dont_show_again">Für diese Version nicht mehr anzeigen</string>
|
||||
<string name="version_notice_ok">OK</string>
|
||||
|
||||
<!-- v2.5.9 — Matrix-Kontakt entfernt (siehe Handoff). -->
|
||||
|
||||
|
||||
<!-- Abspielregeln -->
|
||||
<string name="playback_rules_section">Abspielregeln</string>
|
||||
@@ -192,11 +462,19 @@
|
||||
<!-- Lokale Playlist -->
|
||||
<string name="music_playlist_label">Playlist</string>
|
||||
<string name="music_playlist_empty">Noch keine Audiodateien ausgewählt.</string>
|
||||
<string name="music_playlist_empty_with_library">Noch keine Audiodateien für diese Tour ausgewählt – wähle neue Dateien hinzu oder nimm bekannte aus der Bibliothek.</string>
|
||||
<string name="music_playlist_add">Dateien hinzufügen</string>
|
||||
<string name="music_playlist_pick_from_library">Aus Bibliothek auswählen</string>
|
||||
<string name="music_playlist_clear">Playlist leeren</string>
|
||||
<string name="music_playlist_remove">Entfernen</string>
|
||||
<string name="music_shuffle">Zufällige Reihenfolge</string>
|
||||
|
||||
<!-- Audio-Bibliothek (tour-übergreifend) -->
|
||||
<string name="music_library_picker_title">Bibliothek auswählen</string>
|
||||
<string name="music_library_picker_subtitle">Audiodateien, die bereits zu dieser App hinzugefügt wurden – aus Wegpunkten oder aus den Playlists anderer Touren. Auswahl wird zur aktuellen Playlist hinzugefügt.</string>
|
||||
<string name="music_library_picker_empty">Alle bekannten Audiodateien sind bereits in dieser Playlist.</string>
|
||||
<string name="music_library_picker_add">Hinzufügen</string>
|
||||
|
||||
<!-- Stream-URL -->
|
||||
<string name="music_stream_url_label">Stream-URL</string>
|
||||
<string name="music_stream_url_hint">Direkte http/https-Audio-URL erforderlich (z. B. Internetradio)</string>
|
||||
@@ -204,6 +482,22 @@
|
||||
<string name="music_stream_url_invalid_error">URL muss mit http:// oder https:// beginnen</string>
|
||||
<string name="music_stream_platform_hint">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.</string>
|
||||
|
||||
<!-- Stream-Playlist (ab v2.0.8) -->
|
||||
<string name="music_stream_playlist_label">Stream-Playlist</string>
|
||||
<string name="music_stream_playlist_empty">Noch keine Streams hinzugefügt – unten URL eingeben und „Hinzufügen“ antippen.</string>
|
||||
<string name="music_stream_playlist_empty_error">Bitte mindestens einen Stream zur Playlist hinzufügen.</string>
|
||||
<string name="music_stream_add_section_label">Stream hinzufügen</string>
|
||||
<string name="music_stream_edit_section_label">Stream bearbeiten</string>
|
||||
<string name="music_stream_name_label">Name (optional)</string>
|
||||
<string name="music_stream_name_placeholder">z. B. Deutschlandfunk Kultur</string>
|
||||
<string name="music_stream_add_entry">Hinzufügen</string>
|
||||
<string name="music_stream_save_entry">Übernehmen</string>
|
||||
<string name="music_stream_clear">Playlist leeren</string>
|
||||
<string name="music_stream_edit">Bearbeiten</string>
|
||||
<string name="music_stream_remove">Entfernen</string>
|
||||
<string name="music_stream_move_up">Nach oben</string>
|
||||
<string name="music_stream_move_down">Nach unten</string>
|
||||
|
||||
<!-- Verhalten beim Wegpunkt -->
|
||||
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
|
||||
<string name="music_behavior_pause_resume">Pausieren und fortsetzen</string>
|
||||
@@ -307,4 +601,354 @@
|
||||
<string name="tour_counter_reset_with_apply">Zähler beim Anwenden zurücksetzen</string>
|
||||
<string name="tour_counter_apply_button">Auf alle Wegpunkte anwenden</string>
|
||||
<string name="tour_counter_apply_hint">Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe.</string>
|
||||
|
||||
<!-- Sortier-/Bearbeitungsmodus -->
|
||||
<string name="sort_mode_enter">Reihenfolge bearbeiten</string>
|
||||
<string name="sort_mode_exit">Fertig</string>
|
||||
<string name="sort_mode_disabled_hint">Reihenfolge bearbeiten ist während GPS-Dienst oder Track-Aufzeichnung deaktiviert.</string>
|
||||
<string name="sort_mode_active_hint">Sortiermodus aktiv – ziehe ▲▼ zum Verschieben.</string>
|
||||
<string name="sort_move_up">Nach oben verschieben</string>
|
||||
<string name="sort_move_down">Nach unten verschieben</string>
|
||||
<string name="sort_tours_label">Touren-Reihenfolge</string>
|
||||
|
||||
<!-- Direktes Sortieren / Touren ordnen (v2.0.1) -->
|
||||
<string name="menu_tour_reorder">Touren ordnen</string>
|
||||
<string name="tour_reorder_dialog_hint">Reihenfolge der Touren festlegen. Pfeile zum Verschieben.</string>
|
||||
<string name="reorder_blocked_hint_short">Sortieren während GPS/Aufzeichnung deaktiviert</string>
|
||||
|
||||
<!-- Drag-and-Drop Reihenfolge (v2.0.2) -->
|
||||
<string name="drag_handle_cd">Ziehgriff zum Verschieben</string>
|
||||
<string name="drag_reorder_hint_short">Ziehgriff halten und verschieben</string>
|
||||
|
||||
<!-- Wake Lock / Display aktiv halten -->
|
||||
<string name="menu_wake_lock">Display aktiv halten</string>
|
||||
<string name="wake_lock_title">Display aktiv halten</string>
|
||||
<string name="wake_lock_description">Verhindert, dass sich das Display während einer Tour sperrt. Erhöht den Akkuverbrauch.</string>
|
||||
<string name="wake_lock_mode_never">Nie</string>
|
||||
<string name="wake_lock_mode_foreground">Immer wenn App im Vordergrund</string>
|
||||
<string name="wake_lock_mode_active_tour">Nur bei aktiver Tour/GPS</string>
|
||||
<string name="wake_lock_battery_hint_title">Hinweis zum Akkuverbrauch</string>
|
||||
<string name="wake_lock_battery_hint_msg">Wenn das Display dauerhaft an bleibt, steigt der Akkuverbrauch deutlich. Schließe das Telefon bei längeren Touren möglichst an eine Ladequelle an.</string>
|
||||
<string name="wake_lock_battery_hint_ok">Verstanden</string>
|
||||
|
||||
<!-- Karten-Provider Auswahl -->
|
||||
<string name="map_provider_menu">Karten-Anbieter</string>
|
||||
<string name="map_provider_title">Karten-Anbieter wählen</string>
|
||||
<string name="map_provider_osm">OpenStreetMap (Standard)</string>
|
||||
<string name="map_provider_carto_positron">CartoDB Positron (hell)</string>
|
||||
<string name="map_provider_carto_dark">CartoDB Dark Matter (dunkel)</string>
|
||||
<string name="map_provider_topo">OpenTopoMap (topografisch)</string>
|
||||
<string name="map_provider_esri_satellite">ESRI Satellitenbild</string>
|
||||
<string name="map_provider_esri_hybrid">ESRI Satellit + Beschriftung</string>
|
||||
<string name="map_provider_stadia_alidade">Stadia Alidade</string>
|
||||
<string name="map_provider_stadia_outdoors">Stadia Outdoors</string>
|
||||
|
||||
<!-- PTT-Aufnahme (v2.1.0) -->
|
||||
<string name="ptt_settings_section">Live / Aufnahme</string>
|
||||
<string name="ptt_live_output_toggle">Live-Ausgabe</string>
|
||||
<string name="ptt_live_output_desc">Mikrofon wird über das Ausgabegerät hörbar gemacht.</string>
|
||||
<string name="ptt_recording_toggle">Aufnahme speichern</string>
|
||||
<string name="ptt_recording_desc">PTT-Mitschnitt wird als Audiodatei gespeichert (M4A/AAC).</string>
|
||||
<string name="ptt_format_label">Aufnahme-Format</string>
|
||||
<string name="ptt_format_m4a">M4A/AAC</string>
|
||||
<string name="ptt_format_wav">WAV (in v2.1.0 nicht verfügbar)</string>
|
||||
<string name="ptt_validation_both_off">Mindestens „Live-Ausgabe" oder „Aufnahme speichern" muss aktiv sein. Live wurde automatisch aktiviert.</string>
|
||||
<string name="ptt_recordings_button">Aufnahmen…</string>
|
||||
<string name="ptt_recording_status">Aufnahme läuft</string>
|
||||
<string name="ptt_recording_saved_title">Aufnahme gespeichert</string>
|
||||
<string name="ptt_recording_saved_name">Datei: %1$s</string>
|
||||
<string name="ptt_recording_saved_duration">Dauer: %1$s</string>
|
||||
<string name="ptt_recording_saved_gps">GPS: %1$.5f, %2$.5f</string>
|
||||
<string name="ptt_recording_assign_waypoint">Als Waypoint-Audio zuweisen</string>
|
||||
<string name="ptt_recording_delete">Löschen</string>
|
||||
<string name="ptt_recording_ok">OK</string>
|
||||
<string name="ptt_recordings_title">PTT-Aufnahmen</string>
|
||||
<string name="ptt_recordings_empty">Noch keine Aufnahmen.\nAktiviere „Aufnahme speichern" in den PTT-Einstellungen und starte PTT.</string>
|
||||
<string name="ptt_recording_meta">%1$s · %2$s</string>
|
||||
<string name="ptt_recording_delete_confirm_title">Aufnahme löschen?</string>
|
||||
<string name="ptt_recording_delete_confirm_msg">„%1$s" wird unwiderruflich gelöscht.</string>
|
||||
<string name="ptt_waypoint_chooser_title">Wegpunkt auswählen</string>
|
||||
<string name="ptt_waypoint_chooser_empty">Keine Wegpunkte in dieser Tour.</string>
|
||||
<string name="ptt_waypoint_chooser_empty_hint">Du kannst die Aufnahme direkt als neuen Wegpunkt in der gewählten Tour anlegen.</string>
|
||||
<string name="ptt_waypoint_chooser_tour_label">Tour: %1$s</string>
|
||||
<string name="ptt_waypoint_chooser_create_new">Neuen Wegpunkt aus Aufnahme erstellen</string>
|
||||
<string name="ptt_waypoint_chooser_created">Wegpunkt „%1$s" in Tour „%2$s" angelegt.</string>
|
||||
<string name="ptt_waypoint_chooser_assigned">Audio dem Wegpunkt „%1$s" zugewiesen.</string>
|
||||
<string name="ptt_recording_preview_play">Anhören</string>
|
||||
<string name="ptt_recording_preview_pause">Pause</string>
|
||||
<string name="ptt_recording_autosaved_toast">PTT-Aufnahme gespeichert: %1$s (%2$s)</string>
|
||||
<string name="close">Schließen</string>
|
||||
|
||||
<!-- v2.3.1 — Waypoint dialog play action -->
|
||||
<string name="explorer_action_play_clip">Clip abspielen</string>
|
||||
<string name="explorer_action_pause_clip">Clip pausieren</string>
|
||||
<string name="explorer_action_no_clip">Kein Clip hinterlegt</string>
|
||||
|
||||
<!-- v2.3.1 — Compact Map info & FAB labels -->
|
||||
<string name="explorer_status_compact">Karte · %1$d Wegpunkte</string>
|
||||
<string name="explorer_status_recording_compact">REC · %1$d</string>
|
||||
<string name="explorer_status_draft_compact">Draft · %1$d/%2$d</string>
|
||||
<string name="explorer_fab_track_short">REC</string>
|
||||
<string name="explorer_fab_stop_short">Stop</string>
|
||||
|
||||
<!-- v2.4.0 — Clip-length lines: jetzt nur noch entlang einer bekannten
|
||||
Geometrie (aufgezeichneter Track/Draft oder berechnete Route).
|
||||
Ohne Geometrie wird nur Trigger-Radius/Dauer-Badge angezeigt. -->
|
||||
<string name="map_layers_audio_lines">Clipabschnitte entlang Route/Track</string>
|
||||
<string name="map_layers_audio_lines_hint">Wird nur gezeichnet, wenn ein aufgezeichneter Track/Draft oder eine berechnete Route vorliegt — keine freien Projektionen mehr.</string>
|
||||
<string name="map_layers_audio_lines_legend_short">≤ 15s kurz</string>
|
||||
<string name="map_layers_audio_lines_legend_medium">15–60s mittel</string>
|
||||
<string name="map_layers_audio_lines_legend_long">≥ 60s lang</string>
|
||||
|
||||
<!-- v2.4.0 — MapLibre Engine Cutover -->
|
||||
<string name="explorer_renderer_maplibre_badge">MapLibre · Engine v2.4</string>
|
||||
|
||||
<!-- v2.3.1 — Routing/POI MVP -->
|
||||
<string name="routing_poi_open">Routing & POI</string>
|
||||
<string name="routing_poi_title">Routing & Online-POI (MVP)</string>
|
||||
<string name="routing_poi_search_label">POI-Suche (online)</string>
|
||||
<string name="routing_poi_search_hint">z. B. „bäckerei", „museum", „bahnhof"</string>
|
||||
<string name="routing_poi_search_button">Suchen</string>
|
||||
<string name="routing_poi_search_loading">Suche läuft …</string>
|
||||
<string name="routing_poi_search_no_results">Keine POIs gefunden.</string>
|
||||
<string name="routing_poi_search_error">Suche fehlgeschlagen — bitte Verbindung/Server prüfen.</string>
|
||||
<string name="routing_poi_search_attribution">POI-Quelle: OpenStreetMap · Overpass/Nominatim · Bitte sparsam abfragen.</string>
|
||||
<string name="routing_poi_add_waypoint">Als Wegpunkt</string>
|
||||
<string name="routing_poi_add_storyboard">Ins Storyboard</string>
|
||||
<string name="routing_poi_distance">%1$s entfernt</string>
|
||||
<string name="routing_endpoint_label">Routing-Endpunkt (OSRM-kompatibel)</string>
|
||||
<string name="routing_endpoint_hint">URL, z. B. https://router.project-osrm.org (Demo, Limits) oder eigene self-hosted Instanz.</string>
|
||||
<string name="routing_endpoint_save">Speichern</string>
|
||||
<string name="routing_endpoint_not_configured">Routing-Endpunkt nicht konfiguriert — Storyboard-Linie wird als Luftlinie/Planlinie angezeigt.</string>
|
||||
<string name="routing_endpoint_demo_warning">Demo-OSRM-Server (router.project-osrm.org) hat Rate-Limits und ist nicht für Produktion gedacht.</string>
|
||||
<string name="routing_compute_button">Route berechnen</string>
|
||||
<string name="routing_compute_loading">Route wird berechnet …</string>
|
||||
<string name="routing_compute_error">Routenberechnung fehlgeschlagen — Endpunkt prüfen.</string>
|
||||
<string name="routing_compute_no_points">Mindestens zwei Wegpunkte im Storyboard nötig.</string>
|
||||
<string name="routing_compute_success">Route geladen (%1$s · %2$s).</string>
|
||||
<string name="routing_planline_hint">Planlinie (Luftlinie). Für echte Straßenrouten Endpunkt konfigurieren.</string>
|
||||
|
||||
<!-- v2.4.2 — Explorer-Routenplaner -->
|
||||
<string name="route_planner_title">Routenplanung</string>
|
||||
<string name="route_planner_subtitle">Start, Via-Punkte und Ziel wählen, Route berechnen oder Planlinie nutzen.</string>
|
||||
<string name="route_planner_open">Routenplanung öffnen</string>
|
||||
<string name="route_planner_open_short">Routenplaner</string>
|
||||
<string name="route_planner_section_start">Start</string>
|
||||
<string name="route_planner_section_via">Via-Stopps</string>
|
||||
<string name="route_planner_section_destination">Ziel</string>
|
||||
<string name="route_planner_empty_start">Noch kein Start gewählt</string>
|
||||
<string name="route_planner_empty_destination">Noch kein Ziel gewählt</string>
|
||||
<string name="route_planner_empty_via">Keine Via-Stopps. Beliebige Zwischenstopps lassen sich oben hinzufügen.</string>
|
||||
<string name="route_planner_choose_start">Start wählen</string>
|
||||
<string name="route_planner_choose_destination">Ziel wählen</string>
|
||||
<string name="route_planner_add_via">Via-Stopp hinzufügen</string>
|
||||
<string name="route_planner_clear">Plan zurücksetzen</string>
|
||||
<string name="route_planner_compute">Route berechnen</string>
|
||||
<string name="route_planner_planline">Nur Planlinie zeigen</string>
|
||||
<string name="route_planner_planline_active">Planlinie wird angezeigt.</string>
|
||||
<string name="route_planner_planline_hint">Planlinie ist eine Luftlinie zwischen den Stops — sie folgt keinen Straßen.</string>
|
||||
<string name="route_planner_route_summary">Route: %1$s · %2$s</string>
|
||||
<string name="route_planner_planline_summary">Planlinie: %1$s (Luftlinie)</string>
|
||||
<string name="route_planner_save_as_tour">Als Tour-Entwurf speichern</string>
|
||||
<string name="route_planner_save_as_tour_done">Tour-Entwurf %1$s gespeichert.</string>
|
||||
<string name="route_planner_save_as_tour_error">Tour-Entwurf konnte nicht gespeichert werden.</string>
|
||||
<string name="route_planner_save_as_tour_dialog_title">Tour-Entwurf speichern</string>
|
||||
<string name="route_planner_save_as_tour_dialog_hint">Name der neuen Tour</string>
|
||||
<string name="route_planner_add_stops_as_waypoints">Punkte als Wegpunkte übernehmen</string>
|
||||
<string name="route_planner_add_stops_done">%1$d Wegpunkte hinzugefügt.</string>
|
||||
<string name="route_planner_no_route">Noch keine berechnete Route. Endpunkt konfigurieren und berechnen, oder Planlinie nutzen.</string>
|
||||
<string name="route_planner_endpoint_not_configured">Routing-Endpunkt nicht konfiguriert — nur Planlinie verfügbar.</string>
|
||||
<string name="route_planner_endpoint_configure">Endpunkt einrichten</string>
|
||||
<string name="route_planner_status_ready">Bereit. Stops wählen und berechnen.</string>
|
||||
<string name="route_planner_role_start">Start</string>
|
||||
<string name="route_planner_role_via">Via %1$d</string>
|
||||
<string name="route_planner_role_destination">Ziel</string>
|
||||
<string name="route_planner_picker_title">Quelle wählen</string>
|
||||
<string name="route_planner_picker_current_location">Aktueller Standort</string>
|
||||
<string name="route_planner_picker_current_location_unavailable">Aktueller Standort: noch keine Position</string>
|
||||
<string name="route_planner_picker_map_center">Kartenmitte</string>
|
||||
<string name="route_planner_picker_waypoint">Vorhandenen Wegpunkt wählen</string>
|
||||
<string name="route_planner_picker_waypoint_empty">Keine Wegpunkte in dieser Tour</string>
|
||||
<string name="route_planner_picker_poi">Online-POI suchen</string>
|
||||
<string name="route_planner_picker_manual">Eigene Koordinate eingeben</string>
|
||||
<string name="route_planner_picker_search">Ortssuche (Nominatim)</string>
|
||||
<string name="route_planner_manual_title">Koordinate eingeben</string>
|
||||
<string name="route_planner_manual_lat">Breitengrad (Lat)</string>
|
||||
<string name="route_planner_manual_lon">Längengrad (Lon)</string>
|
||||
<string name="route_planner_manual_apply">Übernehmen</string>
|
||||
<string name="route_planner_manual_invalid">Ungültige Koordinaten.</string>
|
||||
<string name="route_planner_remove_via">Via entfernen</string>
|
||||
<string name="route_planner_move_via_up">Nach oben</string>
|
||||
<string name="route_planner_move_via_down">Nach unten</string>
|
||||
<string name="route_planner_label_current_location">Mein Standort</string>
|
||||
<string name="route_planner_label_map_center">Kartenmitte</string>
|
||||
<string name="route_planner_label_manual">Eigene Koordinate</string>
|
||||
<string name="route_planner_status_error">Routing fehlgeschlagen — Endpunkt prüfen.</string>
|
||||
<string name="route_planner_status_error_with_detail">Routing fehlgeschlagen: %1$s — Endpunkt oder Netzwerk prüfen.</string>
|
||||
<string name="route_planner_status_endpoint_missing">Kein Routing-Endpunkt gesetzt. Mit „OSRM-Demo nutzen\" startest du sofort einen Testlauf, oder richte unter „Endpunkt einrichten\" einen eigenen Server ein.</string>
|
||||
<string name="route_planner_use_demo_endpoint">OSRM-Demo nutzen</string>
|
||||
<string name="route_planner_use_demo_endpoint_hint">Setzt einen öffentlichen OSRM-Testserver (router.project-osrm.org). Nur für Tests/Demo, Rate-Limits, keine Garantie. Bitte sparsam verwenden.</string>
|
||||
<string name="route_planner_demo_endpoint_set">Demo-Endpunkt aktiv (router.project-osrm.org). Jetzt „Route berechnen\" drücken.</string>
|
||||
<string name="routing_endpoint_use_demo">OSRM-Demo-URL einsetzen</string>
|
||||
<string name="routing_endpoint_accepted_formats">Erlaubt: Basis-URL (https://router.project-osrm.org) oder vollständig mit Pfad /route/v1/driving. Wird intern normalisiert.</string>
|
||||
<string name="route_planner_layer_section">Routen-Darstellung</string>
|
||||
<string name="route_planner_layer_show">Route/Planlinie anzeigen</string>
|
||||
<string name="route_planner_layer_hint">Wenn aktiviert, werden berechnete Route, Planlinie und Stop-Marker auf der Karte gezeichnet.</string>
|
||||
<string name="route_planner_legend_route">Berechnete Route</string>
|
||||
<string name="route_planner_legend_planline">Planlinie (Luftlinie)</string>
|
||||
<string name="route_planner_set_as_start">Als Start</string>
|
||||
<string name="route_planner_set_as_via">Als Via</string>
|
||||
<string name="route_planner_set_as_destination">Als Ziel</string>
|
||||
<string name="route_planner_poi_use">Punkt verwenden</string>
|
||||
<string name="route_planner_poi_explanation">Treffer wird als Stopp dem Routenplaner hinzugefügt.</string>
|
||||
<string name="route_planner_use_searched">Gesuchten Ort übernehmen</string>
|
||||
<string name="route_planner_attribution">Routing optional via OSRM-kompatiblem Endpunkt — sparsam abfragen.</string>
|
||||
<!-- v2.5.2 — Routingprofile (Auto/Fahrrad/Fußgänger) -->
|
||||
<string name="route_planner_profile_section">Fortbewegungsmittel</string>
|
||||
<string name="route_planner_profile_driving">Auto</string>
|
||||
<string name="route_planner_profile_cycling">Fahrrad</string>
|
||||
<string name="route_planner_profile_walking">Fußgänger</string>
|
||||
<string name="route_planner_profile_hint">Wird für die Routing-Anfrage verwendet (driving/cycling/walking) und mit der gespeicherten Tourroute übernommen.</string>
|
||||
<string name="route_planner_status_error_with_profile">Routing für „%1$s\" fehlgeschlagen: %2$s — Endpunkt oder Netzwerk prüfen.</string>
|
||||
<string name="route_planner_status_endpoint_missing_with_profile">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.</string>
|
||||
<string name="tour_route_profile_label">Profil: %1$s</string>
|
||||
|
||||
<!-- v2.4.6 — Tourrouten (persistente Route pro Tour) -->
|
||||
<string name="tour_route_status_title">Tourroute „%1$s"</string>
|
||||
<string name="tour_route_status_none">Keine Tourroute gespeichert.</string>
|
||||
<string name="tour_route_updated_at">aktualisiert %1$s</string>
|
||||
<string name="tour_route_source">Quelle: %1$s</string>
|
||||
<string name="tour_route_save">Tourroute speichern</string>
|
||||
<string name="tour_route_replace">Tourroute ersetzen</string>
|
||||
<string name="tour_route_delete">Tourroute löschen</string>
|
||||
<string name="tour_route_hint_compute_first">Erst Route berechnen oder Planlinie zeigen — dann lässt sich die Tourroute dauerhaft speichern.</string>
|
||||
<string name="tour_route_saved_msg">Tourroute für „%1$s" gespeichert.</string>
|
||||
<string name="tour_route_replaced_msg">Tourroute für „%1$s" ersetzt.</string>
|
||||
<string name="tour_route_deleted_msg">Tourroute für „%1$s" gelöscht.</string>
|
||||
<string name="tour_route_loaded_status">Gespeicherte Tourroute geladen (%1$s).</string>
|
||||
<string name="tour_route_source_planline">Planlinie</string>
|
||||
<string name="tour_route_source_track">GPS-Track</string>
|
||||
|
||||
<!-- v2.5.3 — Manueller Routemodus im Explorer -->
|
||||
<string name="explorer_mode_station">Station hinzufügen</string>
|
||||
<string name="explorer_mode_draw_route">Route zeichnen</string>
|
||||
<string name="explorer_mode_draw_route_active">Route zeichnen: Tippe auf die Karte, um den Wegverlauf zu setzen. Nur Stationen spielen Audio ab.</string>
|
||||
<string name="explorer_mode_draw_route_hint">Route zeichnen: Wegverlauf setzen. Nur Stationen spielen Audio ab.</string>
|
||||
<string name="explorer_route_point_dialog_title">Routenpunkt</string>
|
||||
<string name="explorer_route_point_move">Verschieben</string>
|
||||
<string name="explorer_route_point_delete">Löschen</string>
|
||||
<string name="explorer_route_point_move_active">Tippe die neue Position an — der Routenpunkt wird dorthin verschoben.</string>
|
||||
<string name="explorer_manual_route_badge">Manuell gezeichnete Route</string>
|
||||
<string name="explorer_manual_route_cleared">Manuelle Route gelöscht.</string>
|
||||
<string name="explorer_manual_route_saved">Manuelle Route gespeichert (%1$d Punkte).</string>
|
||||
<string name="explorer_manual_route_clear">Manuelle Route löschen</string>
|
||||
<string name="tour_route_source_manual">Manuell gezeichnet</string>
|
||||
|
||||
<string name="version_notice_body_v231">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.</string>
|
||||
|
||||
<!-- v2.5.9 — In-App Nutzungsbedingungen-Dialog -->
|
||||
<string name="terms_dialog_title">Nutzungsbedingungen</string>
|
||||
<string name="terms_dialog_intro">GPS2Audio ist source-available. Vor der ersten Nutzung musst du den folgenden Bedingungen zustimmen. Ohne Zustimmung kann die App nicht verwendet werden.</string>
|
||||
<string name="terms_dialog_license_heading">Lizenz</string>
|
||||
<string name="terms_dialog_license_body">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).</string>
|
||||
<string name="terms_dialog_commercial_heading">Erlaubt / nicht erlaubt</string>
|
||||
<string name="terms_dialog_commercial_body">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.</string>
|
||||
<string name="terms_dialog_liability_heading">Haftungsausschluss</string>
|
||||
<string name="terms_dialog_liability_body">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.</string>
|
||||
<string name="terms_dialog_version_label">Version 2.5.11-terms-1 · Vollständiger Text in TERMS.md</string>
|
||||
<string name="terms_dialog_accept">Ich stimme zu</string>
|
||||
<string name="terms_dialog_decline">Ablehnen und beenden</string>
|
||||
|
||||
<!-- v2.5.13 — Hintergrundwiedergabe absichern (Xiaomi/HyperOS-Hilfe) -->
|
||||
<string name="bg_reliability_title">Hintergrundwiedergabe absichern</string>
|
||||
<string name="bg_reliability_intro">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.</string>
|
||||
<string name="bg_reliability_xiaomi_heading">Empfehlung für Xiaomi / Redmi / Poco (MIUI / HyperOS)</string>
|
||||
<string name="bg_reliability_xiaomi_item_battery">Akku & Leistung: GPS2Audio auf „Keine Einschränkungen“ stellen.</string>
|
||||
<string name="bg_reliability_xiaomi_item_autostart">Hintergrund-Autostart für GPS2Audio erlauben.</string>
|
||||
<string name="bg_reliability_xiaomi_item_lock">In der Recent-Apps-Übersicht das Schloss-Symbol über der GPS2Audio-Karte aktivieren.</string>
|
||||
<string name="bg_reliability_xiaomi_item_notify">Benachrichtigungen für GPS2Audio erlauben.</string>
|
||||
<string name="bg_reliability_generic_heading">Empfehlung</string>
|
||||
<string name="bg_reliability_generic_item_battery">Akkuoptimierung für GPS2Audio deaktivieren.</string>
|
||||
<string name="bg_reliability_generic_item_notify">Benachrichtigungen für GPS2Audio erlauben.</string>
|
||||
<string name="bg_reliability_status_optimized">Status: Akkuoptimierung für GPS2Audio ist aktiv — Wiedergabe kann im Standby gestoppt werden.</string>
|
||||
<string name="bg_reliability_status_ok">Status: Akkuoptimierung für GPS2Audio ist ausgenommen.</string>
|
||||
<string name="bg_reliability_btn_battery">Akkuoptimierung öffnen</string>
|
||||
<string name="bg_reliability_btn_app">App-Einstellungen öffnen</string>
|
||||
<string name="bg_reliability_btn_notify">Benachrichtigungen öffnen</string>
|
||||
<string name="bg_reliability_btn_autostart">MIUI-Autostart öffnen</string>
|
||||
<string name="bg_reliability_dont_show_again">Für diese Version nicht mehr anzeigen</string>
|
||||
<string name="bg_reliability_understood">Verstanden</string>
|
||||
<string name="bg_reliability_settings_entry">Hintergrundwiedergabe</string>
|
||||
<string name="bg_reliability_settings_entry_subtitle">Tipps, falls die Wiedergabe im Standby stoppt</string>
|
||||
|
||||
<!-- v2.5.24 — Lizenzimport-Fallbacks -->
|
||||
<!-- Primärer Dateiimport: Button-Label unverändert -->
|
||||
<!-- Fallback-Textimport -->
|
||||
<string name="about_license_action_paste">Lizenz aus Zwischenablage / Text einfügen</string>
|
||||
<string name="about_license_paste_title">Lizenztext einfügen</string>
|
||||
<string name="about_license_paste_hint">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.</string>
|
||||
<string name="about_license_paste_field_label">JSON-Lizenztext</string>
|
||||
<string name="about_license_paste_from_clipboard">Aus Zwischenablage einfügen</string>
|
||||
<string name="about_license_paste_clipboard_empty">Zwischenablage ist leer.</string>
|
||||
<string name="about_license_paste_empty_error">Kein Text eingegeben.</string>
|
||||
<string name="about_license_paste_import_button">Importieren</string>
|
||||
<!-- Diagnose-Dialog bei Totalausfall aller Lesestrategien -->
|
||||
<string name="about_license_import_err_diag_title">Datei konnte nicht gelesen werden</string>
|
||||
<string name="about_license_import_err_diag_intro">Alle Lesestrategien sind fehlgeschlagen. Technische Details:</string>
|
||||
<string name="about_license_import_err_diag_hint">Tipp: Versuche den Textimport ("Lizenz aus Zwischenablage / Text einfügen") als Alternative.</string>
|
||||
<!-- Version-Notice v2.5.24 -->
|
||||
<string name="version_notice_body_v2524">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.</string>
|
||||
<!-- Version-Notice v2.5.29 -->
|
||||
<string name="version_notice_body_v2537">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.</string>
|
||||
<string name="version_notice_body_v2529">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.</string>
|
||||
|
||||
<!-- v2.5.9 — Erweiterter About-Block (Apache 2.0 + Commons Clause sichtbar) -->
|
||||
<string name="about_license_commons_clause_label">Zusatzklausel</string>
|
||||
<string name="about_license_commons_clause_name">Commons Clause License Condition v1.0</string>
|
||||
<string name="about_license_commons_clause_summary">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.</string>
|
||||
<string name="about_third_party_label">Drittanbieter-Komponenten</string>
|
||||
<string name="about_third_party_text">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.</string>
|
||||
|
||||
<!-- v2.5.38 — Kartenattribution (legal, kompakt + Info-Dialog) -->
|
||||
<string name="map_attribution_compact">© OpenStreetMap · ODbL</string>
|
||||
<string name="map_attribution_badge_label">Karteninfo</string>
|
||||
<string name="map_info_dialog_title">Karteninfo</string>
|
||||
<string name="map_info_style_label">Kartenstil</string>
|
||||
<string name="map_info_style_source">OpenFreeMap / OpenMapTiles</string>
|
||||
<string name="map_info_data_label">Kartendaten</string>
|
||||
<string name="map_info_data_text">© OpenStreetMap-Mitwirkende, ODbL</string>
|
||||
<string name="map_info_links_label">Quellen</string>
|
||||
<string name="map_info_link_ofm">openfreemap.org</string>
|
||||
<string name="map_info_link_omt">openmaptiles.org</string>
|
||||
<string name="map_info_link_osm">openstreetmap.org/copyright</string>
|
||||
<string name="map_info_close">Schließen</string>
|
||||
<string name="map_info_open">Karteninfo</string>
|
||||
|
||||
<!-- v2.5.38 — POI-Layer-Kategorien -->
|
||||
<string name="poi_section_title">POI anzeigen</string>
|
||||
<string name="poi_section_subtitle">Kategorien aktivieren, dann unten auf der Karte „POIs laden“ tippen. Daten aus OpenStreetMap/Overpass — kein API-Key, rate-limit-freundlich.</string>
|
||||
<string name="poi_cat_sights">Sehenswürdigkeiten</string>
|
||||
<string name="poi_cat_infrastructure">Infrastruktur</string>
|
||||
<string name="poi_cat_gastronomy">Gastronomie</string>
|
||||
<string name="poi_cat_mobility">Mobilität</string>
|
||||
<string name="poi_unavailable_classic">POI-Layer nur bei Vektor-Stilen (Modern, Dunkel, Outdoor, 3D Stadt) verfügbar. Im Klassisch-Modus (Raster) nicht anwendbar.</string>
|
||||
|
||||
<!-- v2.5.38 — Version Notice -->
|
||||
<string name="version_notice_body_v2538">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.</string>
|
||||
|
||||
<!-- v2.5.40 — Explorer POI / Kompass / Toolbar -->
|
||||
<string name="explorer_poi_load_button">POIs im Kartenbereich laden</string>
|
||||
<string name="explorer_poi_loading">POIs werden geladen …</string>
|
||||
<string name="explorer_poi_none_found">Keine POIs in diesem Bereich gefunden.</string>
|
||||
<string name="explorer_poi_radius_clamped">Kartenausschnitt zu groß – Abfrage auf max. 5 km Radius begrenzt.</string>
|
||||
<string name="explorer_compass_button">Nach Norden ausrichten</string>
|
||||
<string name="explorer_poi_detail_category">Kategorie</string>
|
||||
<string name="explorer_poi_detail_distance">Entfernung</string>
|
||||
<string name="explorer_poi_detail_tags">OSM-Tags</string>
|
||||
<string name="explorer_poi_detail_attribution">POI-Daten: OpenStreetMap/Overpass (ODbL)</string>
|
||||
<string name="explorer_poi_detail_add_waypoint">Als Wegpunkt</string>
|
||||
<string name="explorer_poi_detail_close">Schließen</string>
|
||||
<string name="explorer_poi_added_snack">als Wegpunkt angelegt.</string>
|
||||
<string name="version_notice_body_v2540">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.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
v2.5.8 — Splash-Theme. Wir benutzen androidx.core.splashscreen, damit
|
||||
auch API 26-30 dieselbe ruhige Intro-Optik wie API 31+ bekommen. Der
|
||||
Splash-Hintergrund ist GPS2Audio-Navy (#0D1B2A), das Logo ist die
|
||||
Map-Pin-/Wellen-Marke. postSplashScreenTheme wechselt nach dem Intro
|
||||
auf das eigentliche App-Theme (unverändert NoActionBar).
|
||||
-->
|
||||
<style name="Theme.WaypointAudioGuide.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.WaypointAudioGuide</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.WaypointAudioGuide" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
v2.5.29 — Legacy-Backup-Regeln (Android < 12).
|
||||
Lizenzdatei und Geräte-ID-SharedPreferences werden vom AutoBackup ausgeschlossen.
|
||||
- license/: App-interne Lizenzdatei (bewusster Ausschluss, separater Export).
|
||||
- gps2audio_license_device.xml: Geräte-UUID-SharedPreferences.
|
||||
- gps2audio_license_store.xml: v2.5.29 — Fallback-Kopie des Lizenz-JSON.
|
||||
|
||||
WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup.
|
||||
APK-Updates lassen App-Daten (filesDir, SharedPreferences) grundsätzlich unangetastet.
|
||||
Der Ausschluss hier führt NICHT dazu, dass normale Updates die Lizenz löschen.
|
||||
-->
|
||||
<full-backup-content>
|
||||
<exclude domain="file" path="license/" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_device.xml" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_store.xml" />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
v2.5.29 — Datenextraktions-/Backup-Regeln.
|
||||
|
||||
Lizenzdatei, Geräte-ID und Lizenz-Fallback-Preferences werden vom
|
||||
automatischen Android-Backup ausgeschlossen.
|
||||
|
||||
- file/license/: Lizenzdatei. Soll separat über den Lizenzbereich der App
|
||||
exportiert/importiert werden, nicht als Beifang eines Gerätebackups.
|
||||
- sharedpref/gps2audio_license_device: Geräte-UUID für Lizenz-Bindung.
|
||||
Bewusst nicht im Cloud-Backup — wenn der Nutzer ein neues Gerät einrichtet,
|
||||
erhält es eine eigene Geräte-ID.
|
||||
- sharedpref/gps2audio_license_store: v2.5.29 — Fallback-Kopie des Lizenz-JSON.
|
||||
Bewusst vom Backup ausgeschlossen (wie primäre Lizenzdatei).
|
||||
|
||||
WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup
|
||||
(Google One / device-transfer). APK-Updates lassen App-Daten grundsätzlich
|
||||
unangetastet. Die hier konfigurierten Ausschlüsse führen NICHT dazu, dass
|
||||
normale Updates die Lizenz löschen.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="file" path="license/" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_device" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_store" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="file" path="license/" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_device" />
|
||||
<exclude domain="sharedpref" path="gps2audio_license_store" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.suppressUnsupportedCompileSdk=35
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=1
|
||||
|
||||
@@ -13,6 +13,12 @@ navigationCompose = "2.8.2"
|
||||
osmdroid = "6.1.20"
|
||||
ksp = "2.0.21-1.0.25"
|
||||
media3 = "1.4.1"
|
||||
splashscreen = "1.0.1"
|
||||
# MapLibre Native Android SDK — wird in v2.2.2 als Abhängigkeit eingezogen,
|
||||
# damit künftige Schritte (Vektor-Tiles, 3D-Extrusions, Style-Layer) ohne
|
||||
# weitere Gradle-Änderung zugänglich sind. Das aktive Rendering läuft in
|
||||
# v2.2.2 weiterhin über osmdroid (Raster), siehe ExplorerScreen.kt.
|
||||
maplibre = "11.5.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -33,8 +39,10 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
|
||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "locationServices" }
|
||||
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
maplibre-android-sdk = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
|
||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exec /home/user/tools/gradle/bin/gradle "$@"
|
||||
@@ -0,0 +1,239 @@
|
||||
# GPS2Audio v2.5.44 — Handoff: POI Category Fix
|
||||
|
||||
**Version:** 2.5.44 · versionCode 98
|
||||
**Basis:** v2.5.43 (Canvas-Piktogramme, versionCode 97)
|
||||
**Datum:** 2025-05-30
|
||||
|
||||
---
|
||||
|
||||
## Artefakte
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|---|---|
|
||||
| `GPS2Audio_v2_5_44_poi_category_fix_universal_signed.apk` | Signierte Universal-Release-APK (57 MB) |
|
||||
| `GPS2Audio_v2_5_44_poi_category_fix_source.zip` | Vollständiger Quellcode (ohne Keystore/Credentials) |
|
||||
| `GPS2Audio_v2_5_44_poi_category_fix_handoff.md` | Dieses Dokument |
|
||||
|
||||
**APK-Prüfung:**
|
||||
- Signatur: APK Signature Scheme v2 ✓ (Verified)
|
||||
- applicationId: `de.waypointaudio`
|
||||
- versionCode: 98
|
||||
- versionName: 2.5.44
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Primärer Bug: Falsche Default-Einstellungen in `PoiLayerSettings`
|
||||
|
||||
In `data/MapStyleSettings.kt` war der Default für alle Nicht-SIGHTS-Kategorien `false`:
|
||||
|
||||
```kotlin
|
||||
// v2.5.43 (FALSCH):
|
||||
data class PoiLayerSettings(
|
||||
val showSights: Boolean = true,
|
||||
val showInfrastructure: Boolean = false, // ← Bug: default aus
|
||||
val showGastronomy: Boolean = false, // ← Bug: default aus
|
||||
val showMobility: Boolean = false, // ← Bug: default aus
|
||||
val showPoiNames: Boolean = false,
|
||||
)
|
||||
```
|
||||
|
||||
Erstnutzer oder Nutzer ohne gespeicherte Preferences sahen deshalb **nur Sehenswürdigkeiten** (SIGHTS). Da `PoiLayerSettings.showSights=true` und der SIGHTS-Filter `historic=*` enthält, wurden ausschließlich historische/touristische POIs geladen und angezeigt.
|
||||
|
||||
### Sekundärer Bug: `humanizeTag()` filterte namenlose Nicht-SIGHTS-POIs heraus
|
||||
|
||||
In `ExplorerPoiClient.humanizeTag()` wurde für die SIGHTS-Kategorie geprüft, ob der Label **nicht** "Sehenswürdigkeit" ist. Für GASTRONOMY/MOBILITY/INFRASTRUCTURE war der Rückgabewert korrekt. Allerdings bedeutete die Logik bei `tourism=attraction`-Elementen ohne Namen: `null → continue → überspringen`. Das betraf hauptsächlich namenlose Sehenswürdigkeiten, aber auch Grenzfälle mit mehreren Tags.
|
||||
|
||||
### Tertiärer Aspekt: Fehlende Kategorie-Diversität bei Default-Ladevorgang
|
||||
|
||||
Da nur SIGHTS geladen wurde und `historic=*` ein sehr breiter Wildcard-Filter ist, dominierten Denkmäler/Historisches die Ergebnisse — auch wenn theoretisch andere tourism-Werte dabei sein sollten.
|
||||
|
||||
---
|
||||
|
||||
## Geänderte Dateien
|
||||
|
||||
### 1. `app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt`
|
||||
|
||||
**Änderung 1:** Default-Werte in `PoiLayerSettings` auf `true` gesetzt:
|
||||
|
||||
```kotlin
|
||||
// v2.5.44 (FIX):
|
||||
data class PoiLayerSettings(
|
||||
val showSights: Boolean = true,
|
||||
val showInfrastructure: Boolean = true, // ← Fix: default an
|
||||
val showGastronomy: Boolean = true, // ← Fix: default an
|
||||
val showMobility: Boolean = true, // ← Fix: default an
|
||||
val showPoiNames: Boolean = false,
|
||||
)
|
||||
```
|
||||
|
||||
**Änderung 2:** DataStore-Fallback ebenfalls auf `true` gesetzt (für Nutzer mit bestehender Installation aber noch nicht gespeicherten Preferences für diese Felder):
|
||||
|
||||
```kotlin
|
||||
poiLayers = PoiLayerSettings(
|
||||
showSights = prefs[KEY_POI_SIGHTS] ?: true,
|
||||
showInfrastructure = prefs[KEY_POI_INFRASTRUCTURE] ?: true, // ← Fix
|
||||
showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true, // ← Fix
|
||||
showMobility = prefs[KEY_POI_MOBILITY] ?: true, // ← Fix
|
||||
showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt`
|
||||
|
||||
**Änderung 1 — `humanizeTag()` korrigiert:**
|
||||
|
||||
Namenlose POIs in GASTRONOMY/MOBILITY/INFRASTRUCTURE werden nun nicht mehr verworfen. Für SIGHTS bleibt die Filterlogik erhalten (generisches "Sehenswürdigkeit" ohne spezifischen Tag = überspringen), aber `tourism=*`-Tags werden jetzt ebenfalls als valide Fallback-Basis anerkannt:
|
||||
|
||||
```kotlin
|
||||
private fun humanizeTag(tags: Map<String, String>, cat: PoiCategory): String? {
|
||||
return when (cat) {
|
||||
PoiCategory.SIGHTS -> {
|
||||
val label = resolveCategoryLabel(tags, cat)
|
||||
when {
|
||||
tags["historic"] != null -> label // Denkmäler etc. immer behalten
|
||||
tags["tourism"] != null -> label // 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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Änderung 2 — `resolveCategoryLabel()` Priorität korrigiert:**
|
||||
|
||||
Die Kategorie-spezifischen Nicht-SIGHTS-Kategorien werden jetzt zuerst ausgewertet, bevor in den SIGHTS-Block gefallen wird:
|
||||
|
||||
```kotlin
|
||||
private fun resolveCategoryLabel(tags: Map<String, String>, cat: PoiCategory): String {
|
||||
// Prüfe zuerst spezifische Nicht-SIGHTS-Tags (GASTRONOMY/MOBILITY/INFRASTRUCTURE)
|
||||
if (cat != PoiCategory.SIGHTS) {
|
||||
when (cat) {
|
||||
PoiCategory.GASTRONOMY -> return when (amenity) { ... }
|
||||
PoiCategory.MOBILITY -> return when { ... }
|
||||
PoiCategory.INFRASTRUCTURE -> return when (amenity) { ... }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// Dann SIGHTS-Mapping
|
||||
return when (cat) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Änderung 3 — `categoryFilters()` erweitert:**
|
||||
|
||||
Neue OSM-Tags hinzugefügt:
|
||||
- SIGHTS: `amenity=place_of_worship`
|
||||
- MOBILITY: `amenity=ferry_terminal`, `railway=tram_stop`, `aeroway=aerodrome`
|
||||
- INFRASTRUCTURE: `amenity=drinking_water`, `amenity=shelter`
|
||||
|
||||
**Änderung 4 — `summarize()` Hilfsfunktion hinzugefügt:**
|
||||
|
||||
```kotlin
|
||||
fun summarize(result: Map<PoiCategory, List<ExplorerPoiResult>>): String {
|
||||
// Gibt z.B. "POIs: 23 geladen (12 Sehensw., 5 Gastro, 3 Mobilität, 3 Infra)" zurück
|
||||
}
|
||||
```
|
||||
|
||||
**Änderung 5 — Logcat-Zusammenfassung nach Laden:**
|
||||
|
||||
```
|
||||
I/ExplorerPoiClient: POI-Load: 23 gesamt — 12 Sehenswürdigkeiten, 5 Gastronomie, 3 Mobilität, 3 Infrastruktur
|
||||
```
|
||||
|
||||
**Änderung 6 — `humanizeHistoric()` erweitert:**
|
||||
|
||||
Neue Historic-Werte: `manor`, `wayside_cross`, `wayside_shrine`, `city_gate`, `locomotive`, `aircraft`, `ship`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt`
|
||||
|
||||
**Änderung — Snackbar-Text nach POI-Laden:**
|
||||
|
||||
Statt nur "Keine POIs gefunden" oder leer bei Erfolg wird nun die Zusammenfassung als Snackbar angezeigt:
|
||||
|
||||
```kotlin
|
||||
explorerPoiSnackbar = if (total == 0) {
|
||||
"Keine POIs in diesem Bereich gefunden."
|
||||
} else {
|
||||
ExplorerPoiClient.summarize(results)
|
||||
}
|
||||
// → "POIs: 23 geladen (12 Sehensw., 5 Gastro, 3 Mobilität, 3 Infra)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Erhaltene Funktionen (keine Regressionen)
|
||||
|
||||
Alle Funktionen aus v2.5.40–v2.5.43 sind unverändert erhalten:
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| Echte Overpass-POIs im Explorer | ✓ unverändert |
|
||||
| POI-Detail-Sheet (RoutingPoiSheet) | ✓ unverändert |
|
||||
| POI als Wegpunkt übernehmen | ✓ unverändert |
|
||||
| Kompass / Nordausrichtung | ✓ unverändert |
|
||||
| Buttonlayout ohne Überlagerung | ✓ unverändert |
|
||||
| Layer-Sheet scrollbar | ✓ unverändert |
|
||||
| Karten-Attribution rechtskonform | ✓ unverändert |
|
||||
| Persistenter Kartentyp / POI-Layer / POI-Namen-Toggle | ✓ unverändert |
|
||||
| Phantom-Track-Fixes | ✓ unverändert |
|
||||
| Routenplaner / Waypoints | ✓ unverändert |
|
||||
| Lizenzsystem | ✓ unverändert |
|
||||
| Multi-Clip / Sequenzplayer | ✓ unverändert |
|
||||
| Canvas-Piktogramme (v2.5.43) | ✓ unverändert |
|
||||
| POI-Legende (v2.5.41) | ✓ unverändert |
|
||||
| POI-Namen-Labels Toggle (v2.5.42) | ✓ unverändert |
|
||||
|
||||
---
|
||||
|
||||
## Tests / Build-Checks
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
|---|---|
|
||||
| Kotlin-Kompilierung (`assembleRelease`) | BUILD SUCCESSFUL |
|
||||
| APK-Signatur-Verifikation (`apksigner verify`) | Verifies ✓ |
|
||||
| APK Scheme v2 | Verified ✓ |
|
||||
| applicationId | `de.waypointaudio` ✓ |
|
||||
| versionCode | 98 ✓ |
|
||||
| versionName | 2.5.44 ✓ |
|
||||
| Runtime-Test | Nicht möglich (kein verbundenes Gerät im Build-Environment) |
|
||||
|
||||
---
|
||||
|
||||
## Bekannte Grenzen
|
||||
|
||||
1. **Nutzer mit gespeicherten Preferences (v2.5.38–v2.5.43):** Wer aktiv Gastronomie/Mobilität/Infrastruktur auf `false` gesetzt hat, behält diese Einstellung (DataStore-Wert ist explizit `false`, Fallback greift nicht). Nur Nutzer, die **nie** die Kategorien geändert haben, bekommen das neue Default.
|
||||
|
||||
2. **Keine Laufzeit-Tests auf echtem Gerät:** Build-Environment hat keinen verbundenen Android-Emulator. POI-Abfragen wurden anhand der Overpass-Abfrage-Logik und Code-Review verifiziert.
|
||||
|
||||
3. **Overpass-Rate-Limits:** Bei gleichzeitigem Laden aller 4 Kategorien werden 4 serielle HTTP-Anfragen abgeschickt (je Kategorie eine). In Spitzenzeiten kann dies zu Timeouts führen (bestehende Logik, kein Regressionsrisiko).
|
||||
|
||||
4. **Cache nicht invalidiert bei Default-Änderung:** Wenn ein Nutzer zuvor bereits POIs geladen hat (nur SIGHTS), zeigt der Cache weiterhin nur SIGHTS bis er manuell leert (App-Neustart oder Tab-Wechsel).
|
||||
|
||||
---
|
||||
|
||||
## Overpass-Abfragen je Kategorie (Beispiel, Radius 1500 m)
|
||||
|
||||
```
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
node[amenity=restaurant](around:1500,...);
|
||||
way[amenity=restaurant](around:1500,...);
|
||||
relation[amenity=restaurant](around:1500,...);
|
||||
node[amenity=cafe](around:1500,...);
|
||||
... (alle GASTRONOMY-Filter als OR-Union)
|
||||
);
|
||||
out center 100;
|
||||
```
|
||||
|
||||
Jede Kategorie ist eine separate Anfrage. Die UI zeigt alle aktivierten Kategorien per OR: wenn GASTRONOMY + MOBILITY aktiv, werden zwei Anfragen abgeschickt und die Ergebnisse zusammengeführt.
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user