Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eeb086481 | |||
| 990b66e24f | |||
| 5cfc5aee1d | |||
| 014e6c1bf1 |
-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.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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.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.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.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
|
## 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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
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 {
|
android {
|
||||||
namespace = "de.waypointaudio"
|
namespace = "de.waypointaudio"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
buildToolsVersion = "35.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "de.waypointaudio"
|
applicationId = "de.waypointaudio"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// v2.5.18 — Bugfix: GPX-Import ist jetzt strikt additiv.
|
||||||
versionName = "1.0"
|
// - 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 = 102
|
||||||
|
versionName = "2.5.48"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
@@ -38,6 +204,11 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError = false
|
||||||
|
checkReleaseBuilds = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -58,7 +229,16 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.play.services)
|
implementation(libs.kotlinx.coroutines.play.services)
|
||||||
implementation(libs.play.services.location)
|
implementation(libs.play.services.location)
|
||||||
implementation(libs.osmdroid)
|
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.exoplayer)
|
||||||
implementation(libs.media3.common)
|
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)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<!-- Foreground service with microphone access (Android 14+) -->
|
<!-- Foreground service with microphone access (Android 14+) -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<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) -->
|
<!-- Audio (MediaPlayer needs no special permission for local files) -->
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
@@ -38,11 +40,13 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".WaypointApp"
|
android:name=".WaypointApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@drawable/ic_launcher"
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.WaypointAudioGuide">
|
android:theme="@style/Theme.WaypointAudioGuide.Splash">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -75,6 +79,23 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="microphone" />
|
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+) -->
|
<!-- File provider for sound file selection (Android 7+) -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -8,14 +8,32 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.MapScreen
|
||||||
import de.waypointaudio.ui.PermissionRequiredScreen
|
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.WaypointListScreen
|
||||||
import de.waypointaudio.ui.theme.WaypointAudioTheme
|
import de.waypointaudio.ui.theme.WaypointAudioTheme
|
||||||
import de.waypointaudio.viewmodel.WaypointViewModel
|
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||||
@@ -40,6 +58,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
|
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
|
||||||
private val locationPermissionsGranted = mutableStateOf(false)
|
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)
|
// Launcher für Standortberechtigungen (zwingend erforderlich)
|
||||||
private val locationPermissionLauncher = registerForActivityResult(
|
private val locationPermissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
@@ -60,31 +88,157 @@ class MainActivity : ComponentActivity() {
|
|||||||
) { /* optional – kein Block der Hauptoberfläche */ }
|
) { /* optional – kein Block der Hauptoberfläche */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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)
|
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
|
// Standortberechtigungen prüfen und ggf. anfordern
|
||||||
checkAndRequestLocationPermissions()
|
checkAndRequestLocationPermissions()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
|
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
|
||||||
val locationGranted by locationPermissionsGranted
|
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()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
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") {
|
composable("list") {
|
||||||
WaypointListScreen(
|
WaypointListScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToMap = { navController.navigate("map") }
|
onNavigateToMap = { navController.navigate("map") },
|
||||||
|
onNavigateToDrafts = { navController.navigate("drafts") },
|
||||||
|
onNavigateToExplorer = { navController.navigate("explorer") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("map") {
|
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(
|
MapScreen(
|
||||||
viewModel = viewModel,
|
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() }
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -105,6 +259,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (locationPermissionsGranted.value) {
|
if (locationPermissionsGranted.value) {
|
||||||
requestOptionalPermissions()
|
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.Application
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import de.waypointaudio.license.LicenseStatusProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application-Klasse – initialisiert globale Ressourcen.
|
* 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() {
|
class WaypointApp : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannels()
|
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() {
|
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 android.content.Context
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
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
|
* Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach
|
||||||
* einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern.
|
* 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 selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard
|
||||||
* @param selectedOutputDeviceId ID des Ausgabegerä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(
|
data class AudioRoutingSettings(
|
||||||
val selectedInputDeviceId: Int? = null,
|
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")
|
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) {
|
class AudioRoutingStore(private val context: Context) {
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
|
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
|
||||||
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_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)
|
// Sentinel: -1 means "use system default" (null cannot be stored as Int)
|
||||||
const val NO_DEVICE = -1
|
const val NO_DEVICE = -1
|
||||||
}
|
}
|
||||||
@@ -42,7 +61,12 @@ class AudioRoutingStore(private val context: Context) {
|
|||||||
.map { prefs ->
|
.map { prefs ->
|
||||||
AudioRoutingSettings(
|
AudioRoutingSettings(
|
||||||
selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +74,9 @@ class AudioRoutingStore(private val context: Context) {
|
|||||||
context.audioRoutingDataStore.edit { prefs ->
|
context.audioRoutingDataStore.edit { prefs ->
|
||||||
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
|
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
|
||||||
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: 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 = CITY_3D
|
||||||
|
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
|
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.
|
* Begleitmusik-Einstellungen für eine Tour.
|
||||||
*
|
*
|
||||||
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
|
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
|
||||||
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
|
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
|
||||||
* @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher.
|
* @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 behavior Verhalten wenn Wegpunkt-Audio abgespielt wird.
|
||||||
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
|
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
|
||||||
* @param duckVolume Lautstärke (0.0–1.0) während des Duck-Modus.
|
* @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 enabled: Boolean = false,
|
||||||
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
|
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
|
||||||
val localPlaylist: List<PlaylistItem> = emptyList(),
|
val localPlaylist: List<PlaylistItem> = emptyList(),
|
||||||
|
val streamPlaylist: List<StreamEntry> = emptyList(),
|
||||||
val streamUrl: String? = null,
|
val streamUrl: String? = null,
|
||||||
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
|
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
|
||||||
val fadeDurationMs: Long = 1500L,
|
val fadeDurationMs: Long = 1500L,
|
||||||
val duckVolume: Float = 0.25f,
|
val duckVolume: Float = 0.25f,
|
||||||
val shuffle: Boolean = false,
|
val shuffle: Boolean = false,
|
||||||
val autoStartAfterWaypoint: 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] ?: "{}"
|
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
|
||||||
runCatching {
|
runCatching {
|
||||||
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
|
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
|
||||||
raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap()
|
raw?.mapValues { (_, v) -> v.toDomain().migrated() } ?: emptyMap()
|
||||||
}.getOrDefault(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(
|
internal data class TourAudioSettingsJson(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val sourceType: String = "LOCAL_PLAYLIST",
|
val sourceType: String = "LOCAL_PLAYLIST",
|
||||||
val localPlaylist: List<PlaylistItemJson> = emptyList(),
|
val localPlaylist: List<PlaylistItemJson> = emptyList(),
|
||||||
|
val streamPlaylist: List<StreamEntryJson>? = null,
|
||||||
val streamUrl: String? = null,
|
val streamUrl: String? = null,
|
||||||
val behavior: String = "PAUSE_RESUME",
|
val behavior: String = "PAUSE_RESUME",
|
||||||
val fadeDurationMs: Long = 1500L,
|
val fadeDurationMs: Long = 1500L,
|
||||||
@@ -97,6 +108,7 @@ internal data class TourAudioSettingsJson(
|
|||||||
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
|
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
|
||||||
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
|
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
|
||||||
localPlaylist = localPlaylist.map { it.toDomain() },
|
localPlaylist = localPlaylist.map { it.toDomain() },
|
||||||
|
streamPlaylist = streamPlaylist?.map { it.toDomain() } ?: emptyList(),
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
|
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
|
||||||
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
|
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
|
||||||
@@ -111,6 +123,7 @@ internal data class TourAudioSettingsJson(
|
|||||||
enabled = s.enabled,
|
enabled = s.enabled,
|
||||||
sourceType = s.sourceType.name,
|
sourceType = s.sourceType.name,
|
||||||
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
|
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
|
||||||
|
streamPlaylist = s.streamPlaylist.map { StreamEntryJson.fromDomain(it) },
|
||||||
streamUrl = s.streamUrl,
|
streamUrl = s.streamUrl,
|
||||||
behavior = s.behavior.name,
|
behavior = s.behavior.name,
|
||||||
fadeDurationMs = s.fadeDurationMs,
|
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 latitude Breitengrad in Dezimalgrad
|
||||||
* @param longitude Längengrad in Dezimalgrad
|
* @param longitude Längengrad in Dezimalgrad
|
||||||
* @param radiusMeters Erkennungsradius in Metern
|
* @param radiusMeters Erkennungsradius in Metern
|
||||||
* @param soundUri URI zur Audiodatei (content:// oder file://)
|
* @param soundUri Legacy-Single-Clip-URI (v2.5.11 und älter). In v2.5.12
|
||||||
* @param soundName Anzeigename der Audiodatei
|
* 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 isActive Ob der Wegpunkt aktiv überwacht wird
|
||||||
* @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel:
|
* @param tourName Name der Tour/Route (Standard: "Standard").
|
||||||
* bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard".
|
|
||||||
*
|
*
|
||||||
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
|
* --- 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 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 allowedStartMinutes Tagesminute (0–1439), ab der das tägliche Zeitfenster beginnt
|
||||||
* @param allowedEndMinutes Tagesminute (0–1439), bis zu der das tägliche Zeitfenster endet
|
* @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(
|
data class Waypoint(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
@@ -61,10 +73,118 @@ data class Waypoint(
|
|||||||
val scheduleStartMillis: Long? = null,
|
val scheduleStartMillis: Long? = null,
|
||||||
val scheduleEndMillis: Long? = null,
|
val scheduleEndMillis: Long? = null,
|
||||||
val allowedStartMinutes: Int? = 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 {
|
companion object {
|
||||||
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
|
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
|
||||||
const val DEFAULT_TOUR_NAME = "Standard"
|
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_TOURS = stringPreferencesKey("tours_json")
|
||||||
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
|
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
|
||||||
private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_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. */
|
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
|
||||||
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
|
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
|
||||||
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
val json = prefs[KEY_WAYPOINTS] ?: "[]"
|
||||||
@@ -72,6 +85,33 @@ class WaypointStore(private val context: Context) {
|
|||||||
}.getOrDefault(emptyMap())
|
}.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. */
|
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
|
||||||
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
|
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
|
||||||
context.dataStore.data.map { prefs ->
|
context.dataStore.data.map { prefs ->
|
||||||
@@ -82,6 +122,59 @@ class WaypointStore(private val context: Context) {
|
|||||||
}.getOrDefault(emptyMap())
|
}.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. */
|
/** Speichert die gesamte Wegpunktliste. */
|
||||||
suspend fun saveAll(waypoints: List<Waypoint>) {
|
suspend fun saveAll(waypoints: List<Waypoint>) {
|
||||||
context.dataStore.edit { prefs ->
|
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. */
|
/** Löscht den GPS-Track einer Tour. */
|
||||||
suspend fun clearGpsTrack(tourName: String) {
|
suspend fun clearGpsTrack(tourName: String) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
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.TrackDraft
|
||||||
|
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(),
|
||||||
|
// v2.5.48 — Echte TrackDraft-Objekte (Name, Notiz, Markierungen, Punkte).
|
||||||
|
// gpsTracks bleibt für die Live-Aufzeichnung weiterhin im Backup-Input,
|
||||||
|
// wird aber NICHT mehr in track_drafts.json geschrieben.
|
||||||
|
val trackDrafts: List<TrackDraft> = emptyList(),
|
||||||
|
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 -----
|
||||||
|
// v2.5.48 FIX: Exportiert die echten TrackDraft-Objekte (Name, Notiz,
|
||||||
|
// Markierungen, Punkte) statt der gpsTracks-Map. Altes Format
|
||||||
|
// ("tracks": { tourName: [GpsTrackPoint] }) wird beim Import
|
||||||
|
// weiterhin toleriert (Abwärtskompatibilität).
|
||||||
|
if (selection.trackDrafts) {
|
||||||
|
val trackPayload = mapOf(
|
||||||
|
"drafts" to input.trackDrafts
|
||||||
|
)
|
||||||
|
writeJsonEntry(zip, "data/track_drafts.json", trackPayload)
|
||||||
|
val pointCount = input.trackDrafts.sumOf { it.points.size }
|
||||||
|
sectionsWritten.add("Track-Entwürfe (${input.trackDrafts.size} Entwürfe, $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,822 @@
|
|||||||
|
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.TrackDraft
|
||||||
|
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(),
|
||||||
|
// v2.5.48 — Echte TrackDraft-Objekte, die als neue Drafts hinzugefügt werden sollen.
|
||||||
|
val trackDraftsToAdd: List<TrackDraft> = emptyList(),
|
||||||
|
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 ----
|
||||||
|
// v2.5.48 FIX: Importiert echte TrackDraft-Objekte aus dem "drafts"-Array.
|
||||||
|
// Abwärtskompatibilität: Alte Backups schrieben fälschlicherweise die
|
||||||
|
// gpsTracks-Map unter "tracks" in track_drafts.json. Falls "drafts" fehlt
|
||||||
|
// aber "tracks" vorhanden ist, wird das alte Format erkannt und toleriert
|
||||||
|
// (kein Crash, keine Datenverluste an bestehenden Touren/Waypoints).
|
||||||
|
val tracksToWrite = mutableMapOf<String, List<GpsTrackPoint>>()
|
||||||
|
val trackDraftsToAdd = mutableListOf<TrackDraft>()
|
||||||
|
var tracksImported = 0
|
||||||
|
if (selection.trackDrafts && draftsJson != null) {
|
||||||
|
val obj = runCatching {
|
||||||
|
JsonParser.parseString(draftsJson).asJsonObject
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (obj != null) {
|
||||||
|
// Neues Format (v2.5.48+): "drafts" enthält eine Liste echter TrackDraft-Objekte.
|
||||||
|
val hasDraftsKey = obj.has("drafts") && !obj.get("drafts").isJsonNull
|
||||||
|
|
||||||
|
if (hasDraftsKey) {
|
||||||
|
// Neues Format: Liste von TrackDraft-Objekten
|
||||||
|
val importedDrafts: List<TrackDraft> = runCatching {
|
||||||
|
gson.fromJson<List<TrackDraft>>(
|
||||||
|
obj.get("drafts"),
|
||||||
|
object : TypeToken<List<TrackDraft>>() {}.type
|
||||||
|
) ?: emptyList()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
importedDrafts.forEach { draft ->
|
||||||
|
// Tourname-Mapping anwenden (für den Fall, dass Tour umbenannt wurde).
|
||||||
|
val origTour = draft.sourceTourName
|
||||||
|
val mappedTour = tourRename[origTour]
|
||||||
|
?: run {
|
||||||
|
if (origTour == Waypoint.DEFAULT_TOUR_NAME ||
|
||||||
|
origTour in resultTours
|
||||||
|
) {
|
||||||
|
origTour
|
||||||
|
} else {
|
||||||
|
resultTours.add(origTour)
|
||||||
|
origTour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neue UUID vergeben, damit importierte Drafts nie mit
|
||||||
|
// vorhandenen Drafts-IDs kollidieren.
|
||||||
|
val newDraft = draft.copy(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
sourceTourName = mappedTour,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
trackDraftsToAdd.add(newDraft)
|
||||||
|
tracksImported++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Altes (fehlerhaftes) Format: "tracks" enthält gpsTracks-Map.
|
||||||
|
// Tolerieren ohne Crash, aber nicht in Tracks oder Drafts importieren,
|
||||||
|
// da der Inhalt GPS-Track-Daten (nicht Draft-Metadaten) sind.
|
||||||
|
// Hinweis in Warnings ausgeben.
|
||||||
|
val hasTracksKey = obj.has("tracks") && !obj.get("tracks").isJsonNull
|
||||||
|
if (hasTracksKey) {
|
||||||
|
warnings.add(
|
||||||
|
"Hinweis: track_drafts.json aus älterem Backup-Format erkannt " +
|
||||||
|
"(enthält GPS-Tracks statt Draft-Objekte). " +
|
||||||
|
"TrackDraft-Namen/Notizen können nicht wiederhergestellt werden. " +
|
||||||
|
"Bestehende Touren und Wegpunkte bleiben unverändert."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keine Daten importieren aus altem, fehlerh. Format — kein Crash.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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,
|
||||||
|
// v2.5.48 — Echte TrackDraft-Objekte weitergeben.
|
||||||
|
trackDraftsToAdd = trackDraftsToAdd,
|
||||||
|
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 android.content.Context
|
||||||
import de.waypointaudio.data.GpsTrackPoint
|
import de.waypointaudio.data.GpsTrackPoint
|
||||||
|
import de.waypointaudio.data.TourCover
|
||||||
import de.waypointaudio.data.TourPlaybackDefaults
|
import de.waypointaudio.data.TourPlaybackDefaults
|
||||||
|
import de.waypointaudio.data.TourRoute
|
||||||
|
import de.waypointaudio.data.TrackDraft
|
||||||
import de.waypointaudio.data.Waypoint
|
import de.waypointaudio.data.Waypoint
|
||||||
import de.waypointaudio.data.WaypointStore
|
import de.waypointaudio.data.WaypointStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -27,6 +30,43 @@ class WaypointRepository(context: Context) {
|
|||||||
/** Reaktiver Flow der gespeicherten GPS-Tracks. */
|
/** Reaktiver Flow der gespeicherten GPS-Tracks. */
|
||||||
val gpsTracks: Flow<Map<String, List<GpsTrackPoint>>> = store.gpsTracksFlow
|
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. */
|
/** Wegpunkt hinzufügen oder aktualisieren. */
|
||||||
suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint)
|
suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint)
|
||||||
|
|
||||||
@@ -73,4 +113,18 @@ class WaypointRepository(context: Context) {
|
|||||||
/** Löscht den GPS-Track einer Tour. */
|
/** Löscht den GPS-Track einer Tour. */
|
||||||
suspend fun clearGpsTrack(tourName: String) =
|
suspend fun clearGpsTrack(tourName: String) =
|
||||||
store.clearGpsTrack(tourName)
|
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
|
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 {
|
companion object {
|
||||||
private const val TAG = "AudioPlayer"
|
private const val TAG = "AudioPlayer"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
package de.waypointaudio.service
|
package de.waypointaudio.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
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.MusicSourceType
|
||||||
import de.waypointaudio.data.PlaylistItem
|
|
||||||
import de.waypointaudio.data.TourAudioSettings
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
import de.waypointaudio.data.WaypointMusicBehavior
|
import de.waypointaudio.data.WaypointMusicBehavior
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -19,12 +11,9 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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.
|
* 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:
|
* Architektur:
|
||||||
* - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert)
|
* - Die eigentliche ExoPlayer-Instanz lebt im Foreground-Service
|
||||||
* - Direkte HTTP/HTTPS Audio-Stream-URLs
|
* [AtmoPlaybackService]. Der Service wird gestartet, sobald
|
||||||
* - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen
|
* [play] / [buildPlayer] aufgerufen werden, und bleibt am Leben, bis
|
||||||
* - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint)
|
* [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.
|
* Fade-/Duck-Animationen werden über kurze Koroutinen umgesetzt, die
|
||||||
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
|
* wiederholt [AtmoPlaybackService.setVolume] aufrufen.
|
||||||
*/
|
*/
|
||||||
class BackgroundMusicPlayer(private val context: Context) {
|
class BackgroundMusicPlayer(private val context: Context) {
|
||||||
|
|
||||||
@@ -76,49 +74,37 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var player: ExoPlayer? = null
|
|
||||||
private var fadeJob: Job? = null
|
private var fadeJob: Job? = null
|
||||||
private var progressJob: Job? = null
|
|
||||||
|
|
||||||
// Aktuell geladene Einstellungen
|
|
||||||
private var currentSettings: TourAudioSettings? = null
|
private var currentSettings: TourAudioSettings? = null
|
||||||
|
|
||||||
// Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade)
|
|
||||||
private var normalVolume: Float = 1.0f
|
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
|
private var waypointSuppressed: Boolean = false
|
||||||
|
|
||||||
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
|
|
||||||
private var wasPlayingBeforeWaypoint: Boolean = false
|
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())
|
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
|
||||||
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
|
val isPlaying: Boolean
|
||||||
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
|
get() = playbackState.value.isPlaying
|
||||||
|
|
||||||
|
/** Ob ein Track geladen ist (auch wenn pausiert). */
|
||||||
|
val hasContent: Boolean
|
||||||
|
get() = playbackState.value.hasContent
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Öffentliche API
|
// Ö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) {
|
fun loadSettings(settings: TourAudioSettings) {
|
||||||
currentSettings = settings
|
currentSettings = settings
|
||||||
normalVolume = 1.0f
|
normalVolume = 1.0f
|
||||||
@@ -126,73 +112,77 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
|
* 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() {
|
fun play() {
|
||||||
val settings = currentSettings ?: return
|
val settings = currentSettings ?: return
|
||||||
if (!settings.enabled) 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) {
|
if (hasContent) {
|
||||||
buildPlayer(settings)
|
// Service hat bereits einen Player – Resume genügt.
|
||||||
|
AtmoPlaybackService.resume(context)
|
||||||
} else {
|
} else {
|
||||||
player?.play()
|
buildPlayer(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
|
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
|
||||||
fun pause() {
|
fun pause() {
|
||||||
cancelFade()
|
cancelFade()
|
||||||
player?.pause()
|
AtmoPlaybackService.pause(context)
|
||||||
updateState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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.
|
* Stoppt Wiedergabe komplett und beendet den Service.
|
||||||
* Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter).
|
* 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) {
|
fun beforeWaypointAudio(settings: TourAudioSettings) {
|
||||||
if (!settings.enabled) return
|
if (!settings.enabled) return
|
||||||
wasPlayingBeforeWaypoint = isPlaying
|
wasPlayingBeforeWaypoint = isPlaying
|
||||||
waypointSuppressed = true
|
waypointSuppressed = true
|
||||||
|
|
||||||
// Nur wenn player vorhanden (Musik läuft/pausiert)
|
if (!hasContent) return
|
||||||
if (player == null) return
|
|
||||||
|
|
||||||
when (settings.behavior) {
|
when (settings.behavior) {
|
||||||
WaypointMusicBehavior.PAUSE_RESUME -> {
|
WaypointMusicBehavior.PAUSE_RESUME -> {
|
||||||
@@ -201,7 +191,7 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
WaypointMusicBehavior.FADE_OUT_IN -> {
|
WaypointMusicBehavior.FADE_OUT_IN -> {
|
||||||
if (wasPlayingBeforeWaypoint) {
|
if (wasPlayingBeforeWaypoint) {
|
||||||
fadeOut(settings.fadeDurationMs) {
|
fadeOut(settings.fadeDurationMs) {
|
||||||
player?.pause()
|
AtmoPlaybackService.pause(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,16 +201,11 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
|
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) {
|
fun afterWaypointAudio(settings: TourAudioSettings) {
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
waypointSuppressed = false
|
waypointSuppressed = false
|
||||||
@@ -228,7 +213,6 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
}
|
}
|
||||||
val wasSuppressed = waypointSuppressed
|
val wasSuppressed = waypointSuppressed
|
||||||
waypointSuppressed = false
|
waypointSuppressed = false
|
||||||
|
|
||||||
if (!wasSuppressed) return
|
if (!wasSuppressed) return
|
||||||
|
|
||||||
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
|
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
|
||||||
@@ -236,15 +220,15 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
when (settings.behavior) {
|
when (settings.behavior) {
|
||||||
WaypointMusicBehavior.PAUSE_RESUME -> {
|
WaypointMusicBehavior.PAUSE_RESUME -> {
|
||||||
if (wasPlayingBeforeWaypoint) {
|
if (wasPlayingBeforeWaypoint) {
|
||||||
player?.play()
|
AtmoPlaybackService.resume(context)
|
||||||
} else if (shouldAutoStart) {
|
} else if (shouldAutoStart) {
|
||||||
startOrPlay(settings)
|
startOrPlay(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WaypointMusicBehavior.FADE_OUT_IN -> {
|
WaypointMusicBehavior.FADE_OUT_IN -> {
|
||||||
if (wasPlayingBeforeWaypoint) {
|
if (wasPlayingBeforeWaypoint) {
|
||||||
player?.volume = 0f
|
AtmoPlaybackService.setVolume(context, 0f)
|
||||||
player?.play()
|
AtmoPlaybackService.resume(context)
|
||||||
fadeIn(settings.fadeDurationMs)
|
fadeIn(settings.fadeDurationMs)
|
||||||
} else if (shouldAutoStart) {
|
} else if (shouldAutoStart) {
|
||||||
startOrPlay(settings)
|
startOrPlay(settings)
|
||||||
@@ -259,9 +243,7 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
|
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
|
||||||
if (shouldAutoStart) {
|
if (shouldAutoStart) startOrPlay(settings)
|
||||||
startOrPlay(settings)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,186 +252,25 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
// Interner Aufbau
|
// Interner Aufbau
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
|
|
||||||
*/
|
|
||||||
private fun startOrPlay(settings: TourAudioSettings) {
|
private fun startOrPlay(settings: TourAudioSettings) {
|
||||||
if (player == null) {
|
if (!hasContent) {
|
||||||
buildPlayer(settings)
|
buildPlayer(settings)
|
||||||
} else {
|
} 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) {
|
private fun buildPlayer(settings: TourAudioSettings) {
|
||||||
releasePlayer()
|
AtmoPlaybackService.start(context, settings)
|
||||||
|
Log.i(TAG, "BackgroundMusicPlayer.buildPlayer → Service gestartet (${settings.sourceType})")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Fade-Helfer (Koroutinen-basiert)
|
// Fade-Helfer
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
private fun cancelFade() {
|
private fun cancelFade() {
|
||||||
@@ -459,7 +280,7 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
|
|
||||||
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
|
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
|
||||||
cancelFade()
|
cancelFade()
|
||||||
val startVolume = player?.volume ?: 1f
|
val startVolume = lastSetVolume.coerceIn(0f, 1f)
|
||||||
fadeJob = scope.launch {
|
fadeJob = scope.launch {
|
||||||
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
||||||
val stepSize = startVolume / steps
|
val stepSize = startVolume / steps
|
||||||
@@ -467,15 +288,15 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
repeat(steps.toInt()) {
|
repeat(steps.toInt()) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
current = (current - stepSize).coerceAtLeast(0f)
|
current = (current - stepSize).coerceAtLeast(0f)
|
||||||
withContext(Dispatchers.Main) { player?.volume = current }
|
AtmoPlaybackService.setVolume(context, current)
|
||||||
|
lastSetVolume = current
|
||||||
delay(FADE_STEP_INTERVAL_MS)
|
delay(FADE_STEP_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
AtmoPlaybackService.setVolume(context, 0f)
|
||||||
player?.volume = 0f
|
lastSetVolume = 0f
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun fadeIn(durationMs: Long) {
|
private fun fadeIn(durationMs: Long) {
|
||||||
cancelFade()
|
cancelFade()
|
||||||
@@ -487,16 +308,18 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
repeat(steps.toInt()) {
|
repeat(steps.toInt()) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
current = (current + stepSize).coerceAtMost(targetVolume)
|
current = (current + stepSize).coerceAtMost(targetVolume)
|
||||||
withContext(Dispatchers.Main) { player?.volume = current }
|
AtmoPlaybackService.setVolume(context, current)
|
||||||
|
lastSetVolume = current
|
||||||
delay(FADE_STEP_INTERVAL_MS)
|
delay(FADE_STEP_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) { player?.volume = targetVolume }
|
AtmoPlaybackService.setVolume(context, targetVolume)
|
||||||
|
lastSetVolume = targetVolume
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
|
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
|
||||||
cancelFade()
|
cancelFade()
|
||||||
val startVolume = player?.volume ?: normalVolume
|
val startVolume = lastSetVolume.coerceIn(0f, 1f)
|
||||||
fadeJob = scope.launch {
|
fadeJob = scope.launch {
|
||||||
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
|
||||||
val delta = (targetVolume - startVolume) / steps
|
val delta = (targetVolume - startVolume) / steps
|
||||||
@@ -504,10 +327,12 @@ class BackgroundMusicPlayer(private val context: Context) {
|
|||||||
repeat(steps.toInt()) {
|
repeat(steps.toInt()) {
|
||||||
if (!isActive) return@launch
|
if (!isActive) return@launch
|
||||||
current = (current + delta).coerceIn(0f, 1f)
|
current = (current + delta).coerceIn(0f, 1f)
|
||||||
withContext(Dispatchers.Main) { player?.volume = current }
|
AtmoPlaybackService.setVolume(context, current)
|
||||||
|
lastSetVolume = current
|
||||||
delay(FADE_STEP_INTERVAL_MS)
|
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
|
package de.waypointaudio.service
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.util.Log
|
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.AudioRoutingSettings
|
||||||
import de.waypointaudio.data.AudioRoutingStore
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -14,23 +22,35 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-weiter Singleton-Manager für Live / PTT.
|
* App-weiter Singleton-Manager für Live / PTT.
|
||||||
*
|
*
|
||||||
* Steuert den [LivePttService] (startet/stoppt den Vordergrundservice) und
|
* Steuert den [LivePttService] (Live-Ausgabe) und/oder den [PttRecorder] (Aufnahme),
|
||||||
* verwaltet den Status (pttActive, Gerätewahl, Fehler).
|
* je nach Nutzer-Einstellungen ([AudioRoutingSettings.liveOutputEnabled],
|
||||||
|
* [AudioRoutingSettings.recordingEnabled]).
|
||||||
*
|
*
|
||||||
* Audio-Priorität:
|
* Audio-Priorität:
|
||||||
* - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]).
|
* - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]).
|
||||||
* - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe.
|
* - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe.
|
||||||
* - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]).
|
* - 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:
|
* Bekannte Einschränkungen:
|
||||||
* - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt.
|
* - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt.
|
||||||
* - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing.
|
* - 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.
|
* - 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) {
|
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private val store = AudioRoutingStore(appContext)
|
private val store = AudioRoutingStore(appContext)
|
||||||
|
private val recordingStore = PttRecordingStore(appContext)
|
||||||
|
private val recorder = PttRecorder(appContext)
|
||||||
|
private val fusedLocation = LocationServices.getFusedLocationProviderClient(appContext)
|
||||||
|
|
||||||
// ─── Öffentliche States ───────────────────────────────────────────────────
|
// ─── Öffentliche States ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -56,15 +79,19 @@ class LivePttManager private constructor(private val appContext: Context) {
|
|||||||
private val _pttActive = MutableStateFlow(false)
|
private val _pttActive = MutableStateFlow(false)
|
||||||
val pttActive: StateFlow<Boolean> = _pttActive.asStateFlow()
|
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())
|
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
|
||||||
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
|
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())
|
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||||
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
|
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())
|
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
|
||||||
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
|
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)
|
private val _statusMessage = MutableStateFlow<String?>(null)
|
||||||
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
|
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 ──────────────────────────────────────────────────────
|
// ─── Initialisierung ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Gespeicherte Einstellungen laden
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
store.settings.collect { settings ->
|
store.settings.collect { settings ->
|
||||||
_routingSettings.value = settings
|
_routingSettings.value = settings
|
||||||
@@ -89,10 +133,7 @@ class LivePttManager private constructor(private val appContext: Context) {
|
|||||||
// ─── PTT starten / stoppen ────────────────────────────────────────────────
|
// ─── PTT starten / stoppen ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet PTT:
|
* Startet PTT (Live und/oder Aufnahme) abhängig von den Einstellungen.
|
||||||
* 1. Atmo pausieren
|
|
||||||
* 2. Foreground Service starten
|
|
||||||
* 3. Status setzen
|
|
||||||
*/
|
*/
|
||||||
fun startPtt() {
|
fun startPtt() {
|
||||||
if (_pttActive.value) {
|
if (_pttActive.value) {
|
||||||
@@ -100,10 +141,34 @@ class LivePttManager private constructor(private val appContext: Context) {
|
|||||||
return
|
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()
|
pauseAtmo()
|
||||||
|
|
||||||
val settings = _routingSettings.value
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (live) {
|
||||||
val intent = Intent(appContext, LivePttService::class.java).apply {
|
val intent = Intent(appContext, LivePttService::class.java).apply {
|
||||||
action = LivePttService.ACTION_START_PTT
|
action = LivePttService.ACTION_START_PTT
|
||||||
settings.selectedInputDeviceId?.let {
|
settings.selectedInputDeviceId?.let {
|
||||||
@@ -113,28 +178,32 @@ class LivePttManager private constructor(private val appContext: Context) {
|
|||||||
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
|
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
appContext.startForegroundService(intent)
|
appContext.startForegroundService(intent)
|
||||||
_pttActive.value = true
|
liveStarted = true
|
||||||
_statusMessage.value = null
|
Log.i(TAG, "PTT-Live gestartet")
|
||||||
Log.i(TAG, "PTT gestartet")
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Log.e(TAG, "PTT konnte nicht gestartet werden", e)
|
Log.e(TAG, "PTT-Live konnte nicht gestartet werden", e)
|
||||||
_statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
|
_statusMessage.value = "PTT-Live-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveStarted || recordStarted) {
|
||||||
|
_pttActive.value = true
|
||||||
|
} else {
|
||||||
|
_statusMessage.value = "PTT konnte nicht gestartet werden"
|
||||||
restoreAtmo()
|
restoreAtmo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stoppt PTT:
|
* Stoppt PTT. Aufnahme wird finalisiert und in der Bibliothek gespeichert;
|
||||||
* 1. Foreground Service stoppen
|
* die fertige Aufnahme wird in [lastRecording] gepostet.
|
||||||
* 2. Atmo wiederherstellen
|
|
||||||
* 3. Status setzen
|
|
||||||
*/
|
*/
|
||||||
fun stopPtt() {
|
fun stopPtt() {
|
||||||
if (!_pttActive.value) return
|
if (!_pttActive.value) return
|
||||||
|
|
||||||
|
// Live-Service stoppen (falls aktiv)
|
||||||
val intent = Intent(appContext, LivePttService::class.java).apply {
|
val intent = Intent(appContext, LivePttService::class.java).apply {
|
||||||
action = LivePttService.ACTION_STOP_PTT
|
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)
|
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
|
_pttActive.value = false
|
||||||
_statusMessage.value = null
|
_statusMessage.value = null
|
||||||
restoreAtmo()
|
restoreAtmo()
|
||||||
@@ -155,63 +244,105 @@ class LivePttManager private constructor(private val appContext: Context) {
|
|||||||
if (_pttActive.value) stopPtt() else startPtt()
|
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 ────────────────────────────────────────────────────
|
// ─── Geräteverwaltung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Lädt die verfügbaren Audiogeräte neu. */
|
|
||||||
fun refreshDevices() {
|
fun refreshDevices() {
|
||||||
_inputDevices.value = AudioDeviceManager.getInputDevices(appContext)
|
_inputDevices.value = AudioDeviceManager.getInputDevices(appContext)
|
||||||
_outputDevices.value = AudioDeviceManager.getOutputDevices(appContext)
|
_outputDevices.value = AudioDeviceManager.getOutputDevices(appContext)
|
||||||
Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe")
|
Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Speichert neue Routing-Einstellungen persistent. */
|
|
||||||
fun saveRoutingSettings(settings: AudioRoutingSettings) {
|
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 {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
store.save(settings)
|
store.save(safe)
|
||||||
Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}")
|
Log.i(TAG, "PTT-Einstellungen gespeichert: live=${safe.liveOutputEnabled}, " +
|
||||||
|
"record=${safe.recordingEnabled}, format=${safe.recordingFormat}")
|
||||||
}.onFailure { e ->
|
}.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() {
|
fun clearStatus() {
|
||||||
_statusMessage.value = null
|
_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 ────────────────────────────────────────────────────
|
// ─── Atmo-Integration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun pauseAtmo() {
|
private fun pauseAtmo() {
|
||||||
|
atmoResume.notifyInterruptStart(AtmoResumeManager.Source.PTT)
|
||||||
runCatching {
|
runCatching {
|
||||||
val mgr = BackgroundMusicManager.getInstance(appContext)
|
WaypointAudioInterruptionCoordinator.beginInterruption()
|
||||||
if (mgr.player.isPlaying) {
|
|
||||||
mgr.pauseMusic()
|
|
||||||
Log.d(TAG, "Atmo pausiert für PTT")
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
}.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() {
|
private fun restoreAtmo() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val mgr = BackgroundMusicManager.getInstance(appContext)
|
WaypointAudioInterruptionCoordinator.endInterruption()
|
||||||
val settings = mgr.currentSettings
|
|
||||||
if (settings.enabled) {
|
|
||||||
mgr.afterWaypointAudio()
|
|
||||||
Log.d(TAG, "Atmo nach PTT wiederhergestellt")
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
}.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 ────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Ressourcen freigeben. Danach nicht mehr nutzbar. */
|
|
||||||
fun release() {
|
fun release() {
|
||||||
if (_pttActive.value) stopPtt()
|
if (_pttActive.value) stopPtt()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.waypointaudio.service
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaPlayer
|
import android.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,8 @@ class ManualAudioPlayer {
|
|||||||
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
private var mediaPlayer: MediaPlayer? = null
|
||||||
private var currentUri: String? = 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.
|
* Whether the player is currently in single-item mode.
|
||||||
@@ -42,6 +45,24 @@ class ManualAudioPlayer {
|
|||||||
val hasTrack: Boolean
|
val hasTrack: Boolean
|
||||||
get() = mediaPlayer != null
|
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)
|
// Playlist mode (existing global manual player bar)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -169,6 +190,9 @@ class ManualAudioPlayer {
|
|||||||
mediaPlayer = MediaPlayer().apply {
|
mediaPlayer = MediaPlayer().apply {
|
||||||
// Grant URI permission for content:// URIs originating from SAF
|
// Grant URI permission for content:// URIs originating from SAF
|
||||||
setDataSource(context, uri)
|
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()
|
prepare()
|
||||||
start()
|
start()
|
||||||
setOnCompletionListener {
|
setOnCompletionListener {
|
||||||
@@ -183,12 +207,43 @@ class ManualAudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentUri = soundUri
|
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 ->
|
}.onFailure { e ->
|
||||||
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
|
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
|
||||||
releasePlayer()
|
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
|
// 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.
|
* Stop and release resources. Clears current track and single-mode state.
|
||||||
*/
|
*/
|
||||||
@@ -231,6 +301,9 @@ class ManualAudioPlayer {
|
|||||||
currentUri = null
|
currentUri = null
|
||||||
isSingleMode = false
|
isSingleMode = false
|
||||||
singleWaypointId = null
|
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 {
|
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.TourAudioSettings
|
||||||
import de.waypointaudio.data.TourMusicStore
|
import de.waypointaudio.data.TourMusicStore
|
||||||
import de.waypointaudio.data.Waypoint
|
import de.waypointaudio.data.Waypoint
|
||||||
|
import de.waypointaudio.data.activeClips
|
||||||
import de.waypointaudio.repository.WaypointRepository
|
import de.waypointaudio.repository.WaypointRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -157,6 +158,9 @@ class WaypointLocationService : Service() {
|
|||||||
private fun stopTracking() {
|
private fun stopTracking() {
|
||||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||||
audioPlayer.stop()
|
audioPlayer.stop()
|
||||||
|
WaypointAudioInterruptionCoordinator.unregister(
|
||||||
|
WaypointAudioInterruptionCoordinator.Source.GPS
|
||||||
|
)
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
Log.i(TAG, "GPS-Tracking gestoppt")
|
Log.i(TAG, "GPS-Tracking gestoppt")
|
||||||
@@ -206,9 +210,10 @@ class WaypointLocationService : Service() {
|
|||||||
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
|
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
|
||||||
updateNotification(msg)
|
updateNotification(msg)
|
||||||
|
|
||||||
// Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt
|
// v2.5.12 — Effektive Clipliste prüfen. activeClips() berücksichtigt
|
||||||
if (waypoint.soundUri.isBlank()) {
|
// sowohl das neue audioClips-Modell als auch Legacy-soundUri.
|
||||||
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei")
|
if (waypoint.activeClips().isEmpty()) {
|
||||||
|
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine aktiven Audioclips")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,21 +251,28 @@ class WaypointLocationService : Service() {
|
|||||||
/**
|
/**
|
||||||
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
|
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
|
||||||
* Kann direkt oder nach PTT-Ende aufgerufen werden.
|
* 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) {
|
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) {
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
val mgr = musicManager
|
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
|
// Tour-Einstellungen laden
|
||||||
val settings: TourAudioSettings? = if (mgr != null) {
|
val settings: TourAudioSettings? = if (mgr != null) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -269,7 +281,7 @@ class WaypointLocationService : Service() {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
// 1. Begleitmusik vor Wegpunkt-Audio anpassen
|
// 1. Begleitmusik einmalig vor erstem Clip anpassen
|
||||||
if (mgr != null && settings != null && settings.enabled) {
|
if (mgr != null && settings != null && settings.enabled) {
|
||||||
runCatching {
|
runCatching {
|
||||||
mgr.currentSettings = settings
|
mgr.currentSettings = settings
|
||||||
@@ -279,19 +291,78 @@ class WaypointLocationService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Wegpunkt-Ton abspielen
|
// 2. Beim PTT-Coordinator registrieren – über die gesamte
|
||||||
// onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen;
|
// Multi-Clip-Sequenz hinweg. Pause/Resume betreffen immer
|
||||||
// wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe.
|
// 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(
|
audioPlayer.play(
|
||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
soundUri = waypoint.soundUri,
|
soundUri = uri,
|
||||||
onCompletion = {
|
onCompletion = {
|
||||||
// Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen
|
|
||||||
serviceScope.launch(Dispatchers.Main) {
|
serviceScope.launch(Dispatchers.Main) {
|
||||||
Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}")
|
Log.d(TAG, "Clip ${index + 1}/${clipUris.size} beendet (${waypoint.name})")
|
||||||
restoreBackgroundMusic(mgr, settings)
|
playClipSequence(waypoint, clipUris, index + 1, mgr, settings)
|
||||||
}
|
}
|
||||||
// playCount auf IO-Thread inkrementieren (Repository-Zugriff)
|
},
|
||||||
|
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) {
|
serviceScope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
repository.incrementPlayCount(waypoint.id)
|
repository.incrementPlayCount(waypoint.id)
|
||||||
@@ -299,15 +370,6 @@ class WaypointLocationService : Service() {
|
|||||||
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -20,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import de.waypointaudio.R
|
import de.waypointaudio.R
|
||||||
import de.waypointaudio.data.MusicSourceType
|
import de.waypointaudio.data.MusicSourceType
|
||||||
import de.waypointaudio.data.PlaylistItem
|
import de.waypointaudio.data.PlaylistItem
|
||||||
|
import de.waypointaudio.data.StreamEntry
|
||||||
import de.waypointaudio.data.TourAudioSettings
|
import de.waypointaudio.data.TourAudioSettings
|
||||||
import de.waypointaudio.data.WaypointMusicBehavior
|
import de.waypointaudio.data.WaypointMusicBehavior
|
||||||
import de.waypointaudio.viewmodel.WaypointViewModel
|
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ü;
|
* Öffnet sich über das sichtbare Begleitmusik-Panel oder über das Overflow-Menü;
|
||||||
* zeigt alle Optionen für die aktuell gewählte Tour.
|
* zeigt alle Optionen für die aktuell gewählte Tour.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BegleitmusikDialog(
|
fun BegleitmusikDialog(
|
||||||
tourName: String,
|
tourName: String,
|
||||||
@@ -40,19 +43,33 @@ fun BegleitmusikDialog(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val initialSettings by viewModel.musicSettings.collectAsState()
|
val initialSettings by viewModel.musicSettings.collectAsState()
|
||||||
val musicPlaying by viewModel.musicPlaying.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
|
// Lokaler Bearbeitungszustand – erst beim Speichern in VM schreiben
|
||||||
var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) }
|
var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) }
|
||||||
var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) }
|
var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) }
|
||||||
var localPlaylist by remember(initialSettings) { mutableStateOf(initialSettings.localPlaylist) }
|
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 behavior by remember(initialSettings) { mutableStateOf(initialSettings.behavior) }
|
||||||
var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) }
|
var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) }
|
||||||
var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) }
|
var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) }
|
||||||
var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) }
|
var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) }
|
||||||
var autoStartAfterWaypoint by remember(initialSettings) { mutableStateOf(initialSettings.autoStartAfterWaypoint) }
|
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("") }
|
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
|
// Launcher für mehrere Audiodateien
|
||||||
val multiPickLauncher = rememberLauncherForActivityResult(
|
val multiPickLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -84,23 +101,19 @@ fun BegleitmusikDialog(
|
|||||||
|
|
||||||
fun validateAndSave(): Boolean {
|
fun validateAndSave(): Boolean {
|
||||||
if (enabled && sourceType == MusicSourceType.STREAM_URL) {
|
if (enabled && sourceType == MusicSourceType.STREAM_URL) {
|
||||||
val url = streamUrl.trim()
|
if (streamPlaylist.isEmpty()) {
|
||||||
if (url.isBlank()) {
|
streamUrlError = context.getString(R.string.music_stream_playlist_empty_error)
|
||||||
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)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
streamUrlError = ""
|
streamUrlError = ""
|
||||||
|
// streamUrl-Legacy-Feld leer lassen – Playlist ist jetzt kanonisch.
|
||||||
val settings = TourAudioSettings(
|
val settings = TourAudioSettings(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
sourceType = sourceType,
|
sourceType = sourceType,
|
||||||
localPlaylist = localPlaylist,
|
localPlaylist = localPlaylist,
|
||||||
streamUrl = streamUrl.trim().ifBlank { null },
|
streamPlaylist = streamPlaylist,
|
||||||
|
streamUrl = null,
|
||||||
behavior = behavior,
|
behavior = behavior,
|
||||||
fadeDurationMs = fadeDurationMs,
|
fadeDurationMs = fadeDurationMs,
|
||||||
duckVolume = duckVolume,
|
duckVolume = duckVolume,
|
||||||
@@ -111,6 +124,18 @@ fun BegleitmusikDialog(
|
|||||||
return true
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = {
|
||||||
@@ -187,10 +212,16 @@ fun BegleitmusikDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
if (localPlaylist.isEmpty()) {
|
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(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
localPlaylist.forEachIndexed { idx, item ->
|
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(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
multiPickLauncher.launch(
|
multiPickLauncher.launch(
|
||||||
@@ -241,6 +276,21 @@ fun BegleitmusikDialog(
|
|||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Text(stringResource(R.string.music_playlist_add))
|
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()) {
|
if (localPlaylist.isNotEmpty()) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { localPlaylist = emptyList() },
|
onClick = { localPlaylist = emptyList() },
|
||||||
@@ -266,17 +316,165 @@ fun BegleitmusikDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stream-URL ───────────────────────────────────────────────
|
// ─── Stream-Playlist ──────────────────────────────────────────
|
||||||
if (sourceType == MusicSourceType.STREAM_URL) {
|
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(
|
OutlinedTextField(
|
||||||
value = streamUrl,
|
value = newStreamName,
|
||||||
onValueChange = { streamUrl = it; streamUrlError = "" },
|
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)) },
|
label = { Text(stringResource(R.string.music_stream_url_label)) },
|
||||||
placeholder = { Text("https://stream.example.com/radio") },
|
placeholder = { Text("https://stream.example.com/radio") },
|
||||||
isError = streamUrlError.isNotBlank(),
|
isError = newStreamError.isNotBlank(),
|
||||||
supportingText = {
|
supportingText = {
|
||||||
if (streamUrlError.isNotBlank()) {
|
if (newStreamError.isNotBlank()) {
|
||||||
Text(streamUrlError, color = MaterialTheme.colorScheme.error)
|
Text(newStreamError, color = MaterialTheme.colorScheme.error)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.music_stream_url_hint),
|
stringResource(R.string.music_stream_url_hint),
|
||||||
@@ -288,6 +486,89 @@ fun BegleitmusikDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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
|
// Hinweis zu YouTube/SoundCloud/radio.de
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
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
|
@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.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.MicOff
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.MenuAnchorType
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -61,27 +63,30 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import de.waypointaudio.R
|
import de.waypointaudio.R
|
||||||
import de.waypointaudio.data.AudioRoutingSettings
|
import de.waypointaudio.data.AudioRoutingSettings
|
||||||
import de.waypointaudio.service.AudioDeviceItem
|
import de.waypointaudio.data.PttRecordingFormat
|
||||||
import de.waypointaudio.service.LivePttManager
|
import de.waypointaudio.service.LivePttManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Karte für Live / PTT auf dem Hauptbildschirm.
|
* Karte für Live / PTT auf dem Hauptbildschirm.
|
||||||
* Platzierung: zwischen Atmo-Mini-Player und Wegpunkt-Tracks-Überschrift.
|
|
||||||
*
|
*
|
||||||
* Features:
|
* v2.1.0:
|
||||||
* - PTT starten / stoppen (Toggle-Button)
|
* - Toggle „Live-Ausgabe" und „Aufnahme speichern" (mit Format M4A/AAC).
|
||||||
* - Statusanzeige (Bereit / Mikrofon aktiv / Berechtigung fehlt)
|
* - Aufnahme-Indikator (rotes Punkt) im aktiven Zustand.
|
||||||
* - Echo-Warnung bei fehlendem Headset
|
* - Knopf „Aufnahmen…" öffnet die Bibliothek; Post-Aufnahme-Dialog erscheint
|
||||||
* - Button zum Öffnen der Audio-Geräte-Einstellungen
|
* automatisch nach jeder gespeicherten Aufnahme.
|
||||||
* - Gerätewahl-Kurzanzeige (gewählte Geräte-Namen)
|
*
|
||||||
|
* @param pttManager Singleton-Manager
|
||||||
|
* @param onOpenRecordings Callback um die Aufnahme-Bibliothek zu öffnen
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LivePttCard(
|
fun LivePttCard(
|
||||||
pttManager: LivePttManager,
|
pttManager: LivePttManager,
|
||||||
|
onOpenRecordings: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val pttActive by pttManager.pttActive.collectAsState()
|
val pttActive by pttManager.pttActive.collectAsState()
|
||||||
|
val recordingActive by pttManager.recordingActive.collectAsState()
|
||||||
val routing by pttManager.routingSettings.collectAsState()
|
val routing by pttManager.routingSettings.collectAsState()
|
||||||
val inputDevices by pttManager.inputDevices.collectAsState()
|
val inputDevices by pttManager.inputDevices.collectAsState()
|
||||||
val statusMsg by pttManager.statusMessage.collectAsState()
|
val statusMsg by pttManager.statusMessage.collectAsState()
|
||||||
@@ -106,12 +111,10 @@ fun LivePttCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geräte laden wenn Karte angezeigt wird
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
pttManager.refreshDevices()
|
pttManager.refreshDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Farbanimation für PTT-Button
|
|
||||||
val pttContainerColor by animateColorAsState(
|
val pttContainerColor by animateColorAsState(
|
||||||
targetValue = if (pttActive)
|
targetValue = if (pttActive)
|
||||||
MaterialTheme.colorScheme.error
|
MaterialTheme.colorScheme.error
|
||||||
@@ -121,24 +124,26 @@ fun LivePttCard(
|
|||||||
label = "pttColor"
|
label = "pttColor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Anzeige-Name des gewählten Eingabegeräts
|
|
||||||
val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) {
|
val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) {
|
||||||
if (routing.selectedInputDeviceId == null) null
|
if (routing.selectedInputDeviceId == null) null
|
||||||
else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName
|
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(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.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(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
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)) {
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
|
||||||
// ── Kopfzeile: Titel + Geräte-Button ─────────────────────────────
|
// ── Kopfzeile: Titel + Aufnahmen-Button + Einstellungen-Button ───
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -158,7 +163,13 @@ fun LivePttCard(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(1f)
|
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(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
pttManager.refreshDevices()
|
pttManager.refreshDevices()
|
||||||
@@ -178,13 +189,13 @@ fun LivePttCard(
|
|||||||
// ── Status-Zeile ──────────────────────────────────────────────────
|
// ── Status-Zeile ──────────────────────────────────────────────────
|
||||||
val statusText = when {
|
val statusText = when {
|
||||||
!hasMicPermission -> stringResource(R.string.ptt_status_permission_missing)
|
!hasMicPermission -> stringResource(R.string.ptt_status_permission_missing)
|
||||||
|
recordingActive -> stringResource(R.string.ptt_recording_status)
|
||||||
pttActive -> stringResource(R.string.ptt_status_active)
|
pttActive -> stringResource(R.string.ptt_status_active)
|
||||||
statusMsg != null -> statusMsg!!
|
statusMsg != null -> statusMsg!!
|
||||||
else -> stringResource(R.string.ptt_status_ready)
|
else -> stringResource(R.string.ptt_status_ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// Status-Indikator (runder Punkt)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
@@ -205,9 +216,17 @@ fun LivePttCard(
|
|||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
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) {
|
if (inputDeviceName != null) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -217,7 +236,6 @@ fun LivePttCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berechtigung verweigert – Hinweis
|
|
||||||
if (showPermissionDeniedHint) {
|
if (showPermissionDeniedHint) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
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))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
// ── PTT-Hauptbutton ───────────────────────────────────────────────
|
// ── PTT-Hauptbutton ───────────────────────────────────────────────
|
||||||
@@ -257,10 +288,9 @@ fun LivePttCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo-Warnung (immer sichtbar wenn kein Headset konfiguriert)
|
|
||||||
val noHeadset = routing.selectedOutputDeviceId == null &&
|
val noHeadset = routing.selectedOutputDeviceId == null &&
|
||||||
routing.selectedInputDeviceId == null
|
routing.selectedInputDeviceId == null
|
||||||
if (noHeadset) {
|
if (noHeadset && routing.liveOutputEnabled) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.ptt_echo_warning),
|
text = stringResource(R.string.ptt_echo_warning),
|
||||||
@@ -271,9 +301,8 @@ fun LivePttCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio-Geräte-Dialog ───────────────────────────────────────────────────
|
|
||||||
if (showDeviceDialog) {
|
if (showDeviceDialog) {
|
||||||
AudioDeviceDialog(
|
PttSettingsDialog(
|
||||||
pttManager = pttManager,
|
pttManager = pttManager,
|
||||||
onDismiss = { showDeviceDialog = false }
|
onDismiss = { showDeviceDialog = false }
|
||||||
)
|
)
|
||||||
@@ -281,12 +310,12 @@ fun LivePttCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Audio-Geräte-Dialog
|
// PTT-Einstellungen Dialog (Live/Aufnahme + Geräte)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioDeviceDialog(
|
fun PttSettingsDialog(
|
||||||
pttManager: LivePttManager,
|
pttManager: LivePttManager,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -294,9 +323,12 @@ fun AudioDeviceDialog(
|
|||||||
val outputDevices by pttManager.outputDevices.collectAsState()
|
val outputDevices by pttManager.outputDevices.collectAsState()
|
||||||
val routing by pttManager.routingSettings.collectAsState()
|
val routing by pttManager.routingSettings.collectAsState()
|
||||||
|
|
||||||
// Lokale Auswahl-States
|
|
||||||
var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) }
|
var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) }
|
||||||
var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) }
|
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 inputExpanded by remember { mutableStateOf(false) }
|
||||||
var outputExpanded by remember { mutableStateOf(false) }
|
var outputExpanded by remember { mutableStateOf(false) }
|
||||||
@@ -314,10 +346,113 @@ fun AudioDeviceDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
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(
|
TextButton(
|
||||||
onClick = { pttManager.refreshDevices() },
|
onClick = { pttManager.refreshDevices() },
|
||||||
modifier = Modifier.align(Alignment.End)
|
modifier = Modifier.align(Alignment.End)
|
||||||
@@ -329,7 +464,6 @@ fun AudioDeviceDialog(
|
|||||||
style = MaterialTheme.typography.labelMedium)
|
style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Eingabegerät (Mikrofon) ───────────────────────────────────
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.audio_input_device_label),
|
text = stringResource(R.string.audio_input_device_label),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -365,7 +499,6 @@ fun AudioDeviceDialog(
|
|||||||
expanded = inputExpanded,
|
expanded = inputExpanded,
|
||||||
onDismissRequest = { inputExpanded = false }
|
onDismissRequest = { inputExpanded = false }
|
||||||
) {
|
) {
|
||||||
// Systemstandard
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -387,7 +520,6 @@ fun AudioDeviceDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ausgabegerät (Lautsprecher) ───────────────────────────────
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.audio_output_device_label),
|
text = stringResource(R.string.audio_output_device_label),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -423,7 +555,6 @@ fun AudioDeviceDialog(
|
|||||||
expanded = outputExpanded,
|
expanded = outputExpanded,
|
||||||
onDismissRequest = { outputExpanded = false }
|
onDismissRequest = { outputExpanded = false }
|
||||||
) {
|
) {
|
||||||
// Systemstandard
|
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
text = { Text(stringResource(R.string.ptt_device_system_default)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -445,7 +576,6 @@ fun AudioDeviceDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hinweis zu Geräte-IDs ─────────────────────────────────────
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.audio_devices_hint),
|
text = stringResource(R.string.audio_devices_hint),
|
||||||
@@ -459,7 +589,10 @@ fun AudioDeviceDialog(
|
|||||||
pttManager.saveRoutingSettings(
|
pttManager.saveRoutingSettings(
|
||||||
AudioRoutingSettings(
|
AudioRoutingSettings(
|
||||||
selectedInputDeviceId = selectedInputId,
|
selectedInputDeviceId = selectedInputId,
|
||||||
selectedOutputDeviceId = selectedOutputId
|
selectedOutputDeviceId = selectedOutputId,
|
||||||
|
liveOutputEnabled = liveEnabled,
|
||||||
|
recordingEnabled = recordEnabled,
|
||||||
|
recordingFormat = format
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
|
|||||||
@@ -0,0 +1,948 @@
|
|||||||
|
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.Explore
|
||||||
|
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 = {},
|
||||||
|
// v2.5.45 — Auto-POI-Load-Toggle
|
||||||
|
poiAutoLoadEnabled: Boolean = true,
|
||||||
|
onPoiAutoLoadChange: (Boolean) -> 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.45 — Auto-POI-Laden Toggle
|
||||||
|
LayerSwitchRow(
|
||||||
|
icon = Icons.Filled.Explore,
|
||||||
|
label = "POIs automatisch nachladen",
|
||||||
|
checked = poiAutoLoadEnabled,
|
||||||
|
onCheckedChange = onPoiAutoLoadChange
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Auto-Laden: POIs werden beim Kartenverschieben aktualisiert (ab Zoom 12)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
|
||||||
|
modifier = Modifier.padding(start = 28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
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.MyLocation
|
||||||
import androidx.compose.material.icons.filled.PlayCircle
|
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.Stop
|
||||||
import androidx.compose.material.icons.filled.Timeline
|
import androidx.compose.material.icons.filled.Timeline
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -72,13 +76,14 @@ import com.google.android.gms.location.Priority
|
|||||||
import com.google.android.gms.tasks.CancellationTokenSource
|
import com.google.android.gms.tasks.CancellationTokenSource
|
||||||
import de.waypointaudio.R
|
import de.waypointaudio.R
|
||||||
import de.waypointaudio.data.GpsTrackPoint
|
import de.waypointaudio.data.GpsTrackPoint
|
||||||
|
import de.waypointaudio.data.MapProvider
|
||||||
|
import de.waypointaudio.data.MapProviderStore
|
||||||
import de.waypointaudio.data.Waypoint
|
import de.waypointaudio.data.Waypoint
|
||||||
import de.waypointaudio.viewmodel.WaypointViewModel
|
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.events.MapEventsReceiver
|
import org.osmdroid.events.MapEventsReceiver
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||||
@@ -116,7 +121,12 @@ import kotlin.math.sqrt
|
|||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(
|
fun MapScreen(
|
||||||
viewModel: WaypointViewModel,
|
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 allWaypoints by viewModel.waypoints.collectAsState()
|
||||||
val selectedTour by viewModel.selectedTour.collectAsState()
|
val selectedTour by viewModel.selectedTour.collectAsState()
|
||||||
@@ -149,6 +159,14 @@ fun MapScreen(
|
|||||||
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
|
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
|
||||||
var showAddWaypointAction by remember { mutableStateOf(false) }
|
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
|
// Editor-Dialog-State
|
||||||
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
||||||
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(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
|
// Bestätigungsdialog für das Löschen eines Wegpunkts
|
||||||
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(null) }
|
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)
|
// Bitmap für Standort-Puck (einmalig erstellt)
|
||||||
val puckBitmap = remember { createLocationPuckBitmap(context) }
|
val puckBitmap = remember { createLocationPuckBitmap(context) }
|
||||||
|
|
||||||
@@ -174,17 +201,27 @@ fun MapScreen(
|
|||||||
LocationServices.getFusedLocationProviderClient(context)
|
LocationServices.getFusedLocationProviderClient(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ────────
|
// ─── Karten-Anbieter Wechsel: Tile-Source aktualisieren und Cache invalidieren ─
|
||||||
LaunchedEffect(waypoints, primaryColor, currentTrack) {
|
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
|
val mv = mapView ?: return@LaunchedEffect
|
||||||
drawMapOverlays(
|
drawMapOverlays(
|
||||||
mv = mv,
|
mv = mv,
|
||||||
waypoints = waypoints,
|
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
|
||||||
trackPoints = currentTrack,
|
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
|
||||||
primaryArgb = primaryColor.toArgb(),
|
primaryArgb = primaryColor.toArgb(),
|
||||||
errorArgb = errorColor.toArgb(),
|
errorArgb = errorColor.toArgb(),
|
||||||
trackArgb = trackColor.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
|
showEditDialog = false
|
||||||
editDialogWaypoint = null
|
editDialogWaypoint = null
|
||||||
editDialogPrefill = 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,10 +391,45 @@ 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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
if (currentMode != null && onModeChange != null) {
|
||||||
|
MapModeSegmentedHeader(
|
||||||
|
current = currentMode,
|
||||||
|
onChange = onModeChange,
|
||||||
|
subtitle = if (tourList.size > 1) selectedTour else null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Column {
|
Column {
|
||||||
Text(stringResource(R.string.map_view))
|
Text(stringResource(R.string.map_view))
|
||||||
if (tourList.size > 1) {
|
if (tourList.size > 1) {
|
||||||
@@ -364,6 +440,7 @@ fun MapScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
@@ -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(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
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(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
MapView(ctx).apply {
|
MapView(ctx).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(selectedProvider.toTileSource())
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
controller.setZoom(12.0)
|
controller.setZoom(12.0)
|
||||||
|
|
||||||
@@ -557,12 +657,15 @@ fun MapScreen(
|
|||||||
mapView = this
|
mapView = this
|
||||||
drawMapOverlays(
|
drawMapOverlays(
|
||||||
mv = this,
|
mv = this,
|
||||||
waypoints = waypoints,
|
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
|
||||||
trackPoints = currentTrack,
|
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
|
||||||
primaryArgb = primaryColor.toArgb(),
|
primaryArgb = primaryColor.toArgb(),
|
||||||
errorArgb = errorColor.toArgb(),
|
errorArgb = errorColor.toArgb(),
|
||||||
trackArgb = trackColor.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
|
mapView = mv
|
||||||
drawMapOverlays(
|
drawMapOverlays(
|
||||||
mv = mv,
|
mv = mv,
|
||||||
waypoints = waypoints,
|
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
|
||||||
trackPoints = currentTrack,
|
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
|
||||||
primaryArgb = primaryColor.toArgb(),
|
primaryArgb = primaryColor.toArgb(),
|
||||||
errorArgb = errorColor.toArgb(),
|
errorArgb = errorColor.toArgb(),
|
||||||
trackArgb = trackColor.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 ───────────────────────────────────────
|
// ─── Track-Statistik-Panel ───────────────────────────────────────
|
||||||
|
// Bottom padding leaves a clear strip for the OSM attribution badge.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isRecording || currentTrack.isNotEmpty(),
|
visible = isRecording || currentTrack.isNotEmpty(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(start = 12.dp, bottom = 12.dp),
|
.padding(start = 12.dp, bottom = 40.dp),
|
||||||
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
|
||||||
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
|
||||||
) {
|
) {
|
||||||
@@ -710,11 +817,12 @@ fun MapScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
|
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
|
||||||
|
// Raised above the OSM attribution strip so the mandatory badge is never covered.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !isRecording && currentTrack.isEmpty(),
|
visible = !isRecording && currentTrack.isEmpty(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 40.dp),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
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,
|
primaryArgb: Int,
|
||||||
errorArgb: Int,
|
errorArgb: Int,
|
||||||
trackArgb: Int,
|
trackArgb: Int,
|
||||||
onWaypointTapped: (Waypoint) -> Unit
|
selectedWaypointId: String?,
|
||||||
|
onWaypointTapped: (Waypoint) -> Unit,
|
||||||
|
searchPin: GeoPoint? = null,
|
||||||
|
showRadius: Boolean = true
|
||||||
) {
|
) {
|
||||||
val overlays = mv.overlays
|
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())
|
overlays.removeAll(overlays.filterIsInstance<Polygon>().toSet())
|
||||||
val waypointMarkers = overlays.filterIsInstance<Marker>()
|
val waypointMarkers = overlays.filterIsInstance<Marker>()
|
||||||
.filter { it.title != "Mein Standort" }
|
.filter { it.title != "Mein Standort" }
|
||||||
overlays.removeAll(waypointMarkers.toSet())
|
overlays.removeAll(waypointMarkers.toSet())
|
||||||
overlays.removeAll(overlays.filterIsInstance<Polyline>().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) {
|
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 {
|
val polyline = Polyline(mv).apply {
|
||||||
setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) })
|
setPoints(pts)
|
||||||
outlinePaint.color = trackArgb
|
outlinePaint.color = trackArgb
|
||||||
outlinePaint.strokeWidth = 6f
|
outlinePaint.strokeWidth = 12f * density / 2f
|
||||||
outlinePaint.alpha = 200
|
outlinePaint.strokeCap = Paint.Cap.ROUND
|
||||||
|
outlinePaint.strokeJoin = Paint.Join.ROUND
|
||||||
|
outlinePaint.alpha = 235
|
||||||
}
|
}
|
||||||
overlays.add(polyline)
|
overlays.add(polyline)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wegpunkte zeichnen
|
// Wegpunkte zeichnen — größere, gut sichtbare Streckenpunkt-Handles
|
||||||
for (wp in waypoints) {
|
for (wp in waypoints) {
|
||||||
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
|
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
|
||||||
|
|
||||||
// Radius-Kreis
|
// Radius-Kreis (nur wenn Layer aktiv)
|
||||||
|
if (showRadius) {
|
||||||
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
|
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
|
||||||
overlays.add(circle)
|
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 {
|
val marker = Marker(mv).apply {
|
||||||
position = geoPoint
|
position = geoPoint
|
||||||
title = wp.name
|
title = wp.name
|
||||||
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
|
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
// ANCHOR_CENTER, ANCHOR_CENTER → Touch-Trefferfläche entspricht der Bitmap-Größe und
|
||||||
alpha = if (wp.isActive) 1.0f else 0.4f
|
// 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
|
// infoWindow = null → Tap löst onMarkerClick aus
|
||||||
setOnMarkerClickListener { _, _ ->
|
setOnMarkerClickListener { _, _ ->
|
||||||
onWaypointTapped(wp)
|
onWaypointTapped(wp)
|
||||||
@@ -797,9 +956,79 @@ private fun drawMapOverlays(
|
|||||||
overlays.add(marker)
|
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()
|
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
|
// 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.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.AudioFile
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
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.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.*
|
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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.waypointaudio.R
|
import de.waypointaudio.R
|
||||||
|
import de.waypointaudio.data.AudioClip
|
||||||
import de.waypointaudio.data.PlaybackMode
|
import de.waypointaudio.data.PlaybackMode
|
||||||
import de.waypointaudio.data.TourPlaybackDefaults
|
import de.waypointaudio.data.TourPlaybackDefaults
|
||||||
import de.waypointaudio.data.Waypoint
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -69,11 +79,23 @@ fun WaypointEditDialog(
|
|||||||
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
|
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
|
||||||
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
|
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
|
||||||
onConfirm: (Waypoint) -> Unit,
|
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 context = LocalContext.current
|
||||||
val isNew = waypoint == null
|
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
|
// Coordinate pre-fill: GPS position takes priority for new waypoints
|
||||||
val initialLat = when {
|
val initialLat = when {
|
||||||
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
|
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
|
||||||
@@ -91,8 +113,18 @@ fun WaypointEditDialog(
|
|||||||
var latStr by remember { mutableStateOf(initialLat) }
|
var latStr by remember { mutableStateOf(initialLat) }
|
||||||
var lngStr by remember { mutableStateOf(initialLng) }
|
var lngStr by remember { mutableStateOf(initialLng) }
|
||||||
var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") }
|
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
|
// Tour name: use existing waypoint's tour, or prefill for new, fallback to default
|
||||||
val initialTour = when {
|
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(
|
val audioPickerLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument()
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
@@ -155,9 +188,31 @@ fun WaypointEditDialog(
|
|||||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
soundUri = uri.toString()
|
val pickedUri = uri.toString()
|
||||||
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
|
val pickedName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
|
||||||
?: uri.toString().takeLast(30)
|
?: 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)
|
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))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
// Coordinates
|
// Coordinates
|
||||||
@@ -362,33 +434,236 @@ fun WaypointEditDialog(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Audio file selection
|
// ---------------------------------------------------------------
|
||||||
|
// 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 (clips.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_sound_selected),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
clips.forEachIndexed { idx, clip ->
|
||||||
|
// v2.5.32: Zusatzclips (idx > 0) sind ohne Profi-Zugang
|
||||||
|
// lesbar (Daten bleiben erhalten), aber nicht bearbeitbar.
|
||||||
|
val isExtraClip = idx > 0
|
||||||
|
val clipEditable = !isExtraClip || canMultiClip
|
||||||
|
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(
|
OutlinedButton(
|
||||||
onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) },
|
onClick = {
|
||||||
|
replaceTargetIndex = null
|
||||||
|
audioPickerLauncher.launch(arrayOf("audio/*"))
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AudioFile,
|
Icons.Filled.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(18.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(stringResource(R.string.choose_sound))
|
Text(
|
||||||
|
if (clips.isEmpty()) stringResource(R.string.choose_sound)
|
||||||
|
else "Weiteren Clip hinzufügen"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} else if (clips.isNotEmpty()) {
|
||||||
if (soundName.isNotBlank()) {
|
// Hauptclip-Audio-Wechsel bleibt immer möglich (index 0)
|
||||||
Text(
|
OutlinedButton(
|
||||||
text = soundName,
|
onClick = {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
replaceTargetIndex = 0
|
||||||
color = MaterialTheme.colorScheme.primary,
|
audioPickerLauncher.launch(arrayOf("audio/*"))
|
||||||
maxLines = 2
|
},
|
||||||
)
|
modifier = Modifier.fillMaxWidth()
|
||||||
} else {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = stringResource(R.string.no_sound_selected),
|
Icons.Filled.SwapHoriz,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
contentDescription = null,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
|
modifier = Modifier.size(18.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Hauptclip-Audio wechseln")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
@@ -702,6 +977,9 @@ fun WaypointEditDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
|
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 = {
|
confirmButton = {
|
||||||
@@ -738,14 +1016,17 @@ fun WaypointEditDialog(
|
|||||||
maxPlayCountError || scheduleRangeError
|
maxPlayCountError || scheduleRangeError
|
||||||
|
|
||||||
if (!hasError) {
|
if (!hasError) {
|
||||||
onConfirm(
|
// v2.5.12 — Clips über withClips() konsistent in audioClips +
|
||||||
(waypoint ?: Waypoint()).copy(
|
// 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(),
|
name = name.trim(),
|
||||||
latitude = lat!!,
|
latitude = lat!!,
|
||||||
longitude = lng!!,
|
longitude = lng!!,
|
||||||
radiusMeters = radius!!,
|
radiusMeters = radius!!,
|
||||||
soundUri = soundUri,
|
|
||||||
soundName = soundName,
|
|
||||||
tourName = safeTourName,
|
tourName = safeTourName,
|
||||||
// Playback rules
|
// Playback rules
|
||||||
playbackMode = playbackMode,
|
playbackMode = playbackMode,
|
||||||
@@ -757,7 +1038,14 @@ fun WaypointEditDialog(
|
|||||||
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
|
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
|
||||||
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes 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))
|
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,631 @@
|
|||||||
|
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.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POI-Untertyp — v2.5.45.
|
||||||
|
*
|
||||||
|
* Abgeleitet aus OSM-Tags (amenity, tourism, historic, railway, highway …).
|
||||||
|
* Wird für Marker-Piktogramm-Varianten und Detail-Sheet genutzt.
|
||||||
|
* KEIN Persistenzmodell-Einfluss: nur In-Memory/Runtime.
|
||||||
|
*
|
||||||
|
* Für SIGHTS detailliert (Burg, Kirche, Museum, Aussicht, Denkmal …);
|
||||||
|
* für GASTRONOMY (Restaurant, Café, Bar, Schnellimbiss);
|
||||||
|
* für MOBILITY und INFRASTRUCTURE: wichtigste Typen.
|
||||||
|
*/
|
||||||
|
enum class PoiSubtype {
|
||||||
|
// SIGHTS
|
||||||
|
CASTLE, // historic=castle|palace|fort
|
||||||
|
RUIN, // historic=ruins|archaeological_site
|
||||||
|
CHURCH, // amenity=place_of_worship + religion=christian | historic=church|chapel|monastery
|
||||||
|
MONASTERY, // historic=monastery
|
||||||
|
MUSEUM, // tourism=museum
|
||||||
|
VIEWPOINT, // tourism=viewpoint
|
||||||
|
MONUMENT, // historic=monument|memorial
|
||||||
|
GALLERY, // tourism=gallery|artwork
|
||||||
|
THEATRE, // amenity=theatre|cinema
|
||||||
|
ZOO, // tourism=zoo|theme_park
|
||||||
|
ATTRACTION, // tourism=attraction (generic)
|
||||||
|
HISTORIC_OTHER, // historic=* (other)
|
||||||
|
// GASTRONOMY
|
||||||
|
RESTAURANT, // amenity=restaurant
|
||||||
|
CAFE, // amenity=cafe
|
||||||
|
BAR, // amenity=bar|pub|biergarten
|
||||||
|
FAST_FOOD, // amenity=fast_food|food_court|ice_cream
|
||||||
|
// MOBILITY
|
||||||
|
PARKING, // amenity=parking
|
||||||
|
BUS_STOP, // highway=bus_stop
|
||||||
|
TRAIN_STATION, // railway=station|halt
|
||||||
|
TRAM_STOP, // railway=tram_stop
|
||||||
|
FUEL, // amenity=fuel|charging_station
|
||||||
|
// INFRASTRUCTURE
|
||||||
|
PHARMACY, // amenity=pharmacy
|
||||||
|
HOSPITAL, // amenity=hospital|clinic
|
||||||
|
TOILETS, // amenity=toilets
|
||||||
|
DRINKING_WATER, // amenity=drinking_water
|
||||||
|
// Generic fallback per category
|
||||||
|
GENERIC_SIGHTS,
|
||||||
|
GENERIC_GASTRONOMY,
|
||||||
|
GENERIC_MOBILITY,
|
||||||
|
GENERIC_INFRASTRUCTURE;
|
||||||
|
|
||||||
|
/** Menschenlesbarer Label für das Detail-Sheet. */
|
||||||
|
val displayLabel: String get() = when (this) {
|
||||||
|
CASTLE -> "Burg/Schloss"
|
||||||
|
RUIN -> "Ruine/Archäologie"
|
||||||
|
CHURCH -> "Kirche/Gotteshaus"
|
||||||
|
MONASTERY -> "Kloster"
|
||||||
|
MUSEUM -> "Museum"
|
||||||
|
VIEWPOINT -> "Aussichtspunkt"
|
||||||
|
MONUMENT -> "Denkmal/Gedenkstätte"
|
||||||
|
GALLERY -> "Galerie/Kunstwerk"
|
||||||
|
THEATRE -> "Theater/Kino"
|
||||||
|
ZOO -> "Zoo/Freizeitpark"
|
||||||
|
ATTRACTION -> "Sehenswürdigkeit"
|
||||||
|
HISTORIC_OTHER -> "Historisch"
|
||||||
|
RESTAURANT -> "Restaurant"
|
||||||
|
CAFE -> "Café"
|
||||||
|
BAR -> "Bar/Pub"
|
||||||
|
FAST_FOOD -> "Schnellimbiss"
|
||||||
|
PARKING -> "Parkplatz"
|
||||||
|
BUS_STOP -> "Bushaltestelle"
|
||||||
|
TRAIN_STATION -> "Bahnhof"
|
||||||
|
TRAM_STOP -> "Straßenbahnhaltestelle"
|
||||||
|
FUEL -> "Tankstelle/Ladestation"
|
||||||
|
PHARMACY -> "Apotheke"
|
||||||
|
HOSPITAL -> "Krankenhaus/Klinik"
|
||||||
|
TOILETS -> "Toiletten"
|
||||||
|
DRINKING_WATER -> "Trinkwasser"
|
||||||
|
GENERIC_SIGHTS -> "Sehenswürdigkeit"
|
||||||
|
GENERIC_GASTRONOMY -> "Gastronomie"
|
||||||
|
GENERIC_MOBILITY -> "Mobilität"
|
||||||
|
GENERIC_INFRASTRUCTURE -> "Infrastruktur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]
|
||||||
|
*
|
||||||
|
* v2.5.45 — Subtype-Feld ergänzt (kein Backup-/Persistenzmodell-Einfluss).
|
||||||
|
*/
|
||||||
|
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 subtype: PoiSubtype = PoiSubtype.GENERIC_SIGHTS, // v2.5.45
|
||||||
|
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.45 (Explorer POI Autoload).
|
||||||
|
*
|
||||||
|
* Rate-Limit-Strategie:
|
||||||
|
* - Automatisches Polling auf explizite Nutzerwahl — standardmäßig aktiviert,
|
||||||
|
* aber kontrolliert:
|
||||||
|
* • Debounce 1.5 s nach Kartenbewegung
|
||||||
|
* • Nur wenn POI-Layer aktiv
|
||||||
|
* • Mindest-Zoom ≥ 12 (kein Auto-Laden bei sehr weitem Ausschnitt)
|
||||||
|
* • Cache-basiert: neue Anfrage nur bei deutlicher Bewegung (> MOVE_THRESHOLD_M)
|
||||||
|
* oder Zoom-/Kategorie-Wechsel
|
||||||
|
* • Max-Radius ≤ 5 km (wie bisher)
|
||||||
|
* - Manueller Lade-Button als Fallback bleibt erhalten.
|
||||||
|
* - In-Memory-Cache je Kategorie + BBox-Fingerprint.
|
||||||
|
* - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie.
|
||||||
|
*
|
||||||
|
* v2.5.45 — Subtype-Auflösung aus OSM-Tags (kein Persistenzmodell-Einfluss).
|
||||||
|
*
|
||||||
|
* OSM-Tags je Kategorie: (unverändert zu v2.5.44)
|
||||||
|
*
|
||||||
|
* SIGHTS: tourism=attraction|viewpoint|museum|gallery|artwork|zoo|theme_park,
|
||||||
|
* historic=*, 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
|
||||||
|
|
||||||
|
// v2.5.46 — Auto-Load-Parameter (erhöhte Schwellen für weniger Flackern)
|
||||||
|
/** Minimaler Zoom für automatisches Laden (unter diesem Zoom: kein Auto-Load). */
|
||||||
|
const val AUTO_LOAD_MIN_ZOOM = 12.0
|
||||||
|
/** Mindest-Kartenbewegung in Metern, bevor ein neuer Auto-Load ausgelöst wird.
|
||||||
|
* v2.5.46: erhöht von 300 m auf 600 m, um überflüssige Nachlades bei kleinen
|
||||||
|
* Kamerabewegungen (Kippen, Heading-Änderungen, kleiner Zoom) zu vermeiden. */
|
||||||
|
const val MOVE_THRESHOLD_M = 600.0
|
||||||
|
|
||||||
|
// In-Memory-Cache: key = "kategorie|bboxFingerprint"
|
||||||
|
private val cache = mutableMapOf<String, List<ExplorerPoiResult>>()
|
||||||
|
|
||||||
|
/** Cache leeren — z. B. beim Verlassen des Explorers oder bei Kategorie-Wechsel. */
|
||||||
|
fun clearCache() = cache.clear()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob ein Auto-Load sinnvoll ist.
|
||||||
|
* Gibt true zurück wenn:
|
||||||
|
* - Mindestens ein POI-Layer aktiv ist
|
||||||
|
* - Zoom ≥ AUTO_LOAD_MIN_ZOOM
|
||||||
|
* - Karte hat sich um > MOVE_THRESHOLD_M vom letzten Auto-Load-Zentrum entfernt
|
||||||
|
* ODER Zoom hat sich um > 0.8 verändert ODER Kategorien haben sich geändert.
|
||||||
|
*/
|
||||||
|
fun shouldAutoLoad(
|
||||||
|
zoom: Double,
|
||||||
|
centerLat: Double,
|
||||||
|
centerLon: Double,
|
||||||
|
lastAutoLat: Double?,
|
||||||
|
lastAutoLon: Double?,
|
||||||
|
lastAutoZoom: Double?,
|
||||||
|
lastAutoCategories: Set<PoiCategory>?,
|
||||||
|
activeCategories: Set<PoiCategory>,
|
||||||
|
): Boolean {
|
||||||
|
if (activeCategories.isEmpty()) return false
|
||||||
|
if (zoom < AUTO_LOAD_MIN_ZOOM) return false
|
||||||
|
if (lastAutoLat == null || lastAutoLon == null || lastAutoZoom == null || lastAutoCategories == null) return true
|
||||||
|
if (activeCategories != lastAutoCategories) return true
|
||||||
|
val moved = haversineM(centerLat, centerLon, lastAutoLat, lastAutoLon)
|
||||||
|
// v2.5.46: Zoom-Delta-Schwelle erhöht auf 1.2, damit minimales Reinzoomen
|
||||||
|
// (z. B. Pitch-Änderung oder sehr kleiner Zoom) keinen Reload auslöst.
|
||||||
|
val zoomDelta = kotlin.math.abs(zoom - lastAutoZoom)
|
||||||
|
return moved > MOVE_THRESHOLD_M || zoomDelta > 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val catLabel = resolveCategoryLabel(tagsMap, category)
|
||||||
|
val subtype = resolveSubtype(tagsMap, category) // v2.5.45
|
||||||
|
|
||||||
|
val name = tagsMap["name"]?.takeIf { it.isNotBlank() }
|
||||||
|
?: tagsMap["ref"]?.takeIf { it.isNotBlank() }
|
||||||
|
?: humanizeTag(tagsMap, category)
|
||||||
|
?: continue
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
ExplorerPoiResult(
|
||||||
|
osmId = osmId,
|
||||||
|
osmType = osmType,
|
||||||
|
displayName = name,
|
||||||
|
category = category,
|
||||||
|
categoryLabel = catLabel,
|
||||||
|
subtype = subtype,
|
||||||
|
latitude = lat,
|
||||||
|
longitude = lon,
|
||||||
|
rawTags = tagsMap,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.5.45 — Subtype aus OSM-Tags ableiten (In-Memory only, kein Backup-Einfluss).
|
||||||
|
*/
|
||||||
|
private fun resolveSubtype(tags: Map<String, String>, cat: PoiCategory): PoiSubtype {
|
||||||
|
val amenity = tags["amenity"]
|
||||||
|
val tourism = tags["tourism"]
|
||||||
|
val historic = tags["historic"]
|
||||||
|
val railway = tags["railway"]
|
||||||
|
val highway = tags["highway"]
|
||||||
|
return when (cat) {
|
||||||
|
PoiCategory.SIGHTS -> when {
|
||||||
|
historic == "castle" || historic == "palace" || historic == "fort" -> PoiSubtype.CASTLE
|
||||||
|
historic == "ruins" || historic == "archaeological_site" -> PoiSubtype.RUIN
|
||||||
|
historic == "monastery" -> PoiSubtype.MONASTERY
|
||||||
|
historic == "church" -> PoiSubtype.CHURCH
|
||||||
|
historic == "monument" || historic == "memorial" -> PoiSubtype.MONUMENT
|
||||||
|
historic != null -> PoiSubtype.HISTORIC_OTHER
|
||||||
|
amenity == "place_of_worship" -> PoiSubtype.CHURCH
|
||||||
|
tourism == "museum" -> PoiSubtype.MUSEUM
|
||||||
|
tourism == "viewpoint" -> PoiSubtype.VIEWPOINT
|
||||||
|
tourism == "gallery" || tourism == "artwork" -> PoiSubtype.GALLERY
|
||||||
|
tourism == "zoo" || tourism == "theme_park" -> PoiSubtype.ZOO
|
||||||
|
amenity == "theatre" || amenity == "cinema" || amenity == "arts_centre" -> PoiSubtype.THEATRE
|
||||||
|
tourism == "attraction" -> PoiSubtype.ATTRACTION
|
||||||
|
else -> PoiSubtype.GENERIC_SIGHTS
|
||||||
|
}
|
||||||
|
PoiCategory.GASTRONOMY -> when (amenity) {
|
||||||
|
"restaurant" -> PoiSubtype.RESTAURANT
|
||||||
|
"cafe" -> PoiSubtype.CAFE
|
||||||
|
"bar", "pub", "biergarten" -> PoiSubtype.BAR
|
||||||
|
"fast_food", "food_court", "ice_cream" -> PoiSubtype.FAST_FOOD
|
||||||
|
else -> PoiSubtype.GENERIC_GASTRONOMY
|
||||||
|
}
|
||||||
|
PoiCategory.MOBILITY -> when {
|
||||||
|
amenity == "parking" || amenity == "bicycle_parking" -> PoiSubtype.PARKING
|
||||||
|
highway == "bus_stop" || amenity == "bus_station" -> PoiSubtype.BUS_STOP
|
||||||
|
railway == "station" || railway == "halt" -> PoiSubtype.TRAIN_STATION
|
||||||
|
railway == "tram_stop" -> PoiSubtype.TRAM_STOP
|
||||||
|
amenity == "fuel" || amenity == "charging_station" -> PoiSubtype.FUEL
|
||||||
|
else -> PoiSubtype.GENERIC_MOBILITY
|
||||||
|
}
|
||||||
|
PoiCategory.INFRASTRUCTURE -> when (amenity) {
|
||||||
|
"pharmacy" -> PoiSubtype.PHARMACY
|
||||||
|
"hospital", "clinic" -> PoiSubtype.HOSPITAL
|
||||||
|
"toilets" -> PoiSubtype.TOILETS
|
||||||
|
"drinking_water" -> PoiSubtype.DRINKING_WATER
|
||||||
|
else -> PoiSubtype.GENERIC_INFRASTRUCTURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
PoiCategory.GASTRONOMY -> "Gastronomie"
|
||||||
|
PoiCategory.MOBILITY -> "Mobilität"
|
||||||
|
PoiCategory.INFRASTRUCTURE-> "Infrastruktur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
tags["tourism"] != null -> label
|
||||||
|
tags["amenity"] in setOf(
|
||||||
|
"theatre","arts_centre","cinema","place_of_worship"
|
||||||
|
) -> label
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.)"
|
||||||
|
"monastery" -> "Kloster"
|
||||||
|
"palace" -> "Schloss/Palast"
|
||||||
|
"fort" -> "Festung"
|
||||||
|
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_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>
|
<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 -->
|
<!-- UI strings -->
|
||||||
<string name="add_waypoint">Wegpunkt hinzufügen</string>
|
<string name="add_waypoint">Wegpunkt hinzufügen</string>
|
||||||
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
|
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
|
||||||
@@ -50,12 +56,58 @@
|
|||||||
<string name="map_view">Karte</string>
|
<string name="map_view">Karte</string>
|
||||||
<string name="map_attribution">Kartendaten © OpenStreetMap-Mitwirkende</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 -->
|
<!-- Map: Als Wegpunkt übernehmen -->
|
||||||
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
|
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
|
||||||
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
|
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
|
||||||
|
|
||||||
<!-- Map Editor -->
|
<!-- 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_edit">Bearbeiten</string>
|
||||||
<string name="map_waypoint_delete">Löschen</string>
|
<string name="map_waypoint_delete">Löschen</string>
|
||||||
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
|
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
|
||||||
@@ -84,28 +136,246 @@
|
|||||||
<string name="menu_about">Über diese App</string>
|
<string name="menu_about">Über diese App</string>
|
||||||
<string name="about_title">Über diese App</string>
|
<string name="about_title">Über diese App</string>
|
||||||
<string name="about_app_name">GPS2Audio</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_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_label">Entwickler</string>
|
||||||
<string name="about_developer">Marcel Mayer</string>
|
<string name="about_developer">Marcel Mayer</string>
|
||||||
<string name="about_email_label">E-Mail</string>
|
<string name="about_email_label">E-Mail</string>
|
||||||
<string name="about_email">marcel.mayer@nesohub.org</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_label">Lizenz</string>
|
||||||
<string name="about_license_name">Apache License 2.0</string>
|
<string name="about_license_name">Apache License 2.0 + Commons Clause</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_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_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_close">Schließen</string>
|
||||||
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
|
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
|
||||||
|
|
||||||
<!-- Matrix contact -->
|
<!-- v2.5.17 — Lizenz & Nutzung / GPS2Audio Pro
|
||||||
<string name="about_matrix_label">Matrix-Kontakt</string>
|
Die bisherige Spenden-/„Entwicklung unterstützen“-Sektion wurde
|
||||||
<string name="about_matrix_description">Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren.</string>
|
entfernt und durch einen professionellen Lizenz-Hinweis ersetzt.
|
||||||
<string name="about_matrix_id">@neso:nesohub.org</string>
|
Der PayPal-Link bleibt erhalten, dient aber nicht mehr als
|
||||||
<string name="about_matrix_url">https://matrix.to/#/@neso:nesohub.org</string>
|
Spenden-Aufruf, sondern als Kanal für eine Pro-Lizenz-Anfrage. -->
|
||||||
<string name="about_matrix_open_button">Matrix-Chat öffnen</string>
|
<string name="about_pro_section_label">Lizenz & Nutzung</string>
|
||||||
<string name="about_matrix_open_cd">Matrix-Chat mit @neso:nesohub.org öffnen</string>
|
<string name="about_pro_title">GPS2Audio Profi</string>
|
||||||
<string name="about_matrix_qr_content_description">QR-Code für Matrix-Kontakt @neso:nesohub.org</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 -->
|
<!-- Abspielregeln -->
|
||||||
<string name="playback_rules_section">Abspielregeln</string>
|
<string name="playback_rules_section">Abspielregeln</string>
|
||||||
@@ -192,11 +462,19 @@
|
|||||||
<!-- Lokale Playlist -->
|
<!-- Lokale Playlist -->
|
||||||
<string name="music_playlist_label">Playlist</string>
|
<string name="music_playlist_label">Playlist</string>
|
||||||
<string name="music_playlist_empty">Noch keine Audiodateien ausgewählt.</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_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_clear">Playlist leeren</string>
|
||||||
<string name="music_playlist_remove">Entfernen</string>
|
<string name="music_playlist_remove">Entfernen</string>
|
||||||
<string name="music_shuffle">Zufällige Reihenfolge</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 -->
|
<!-- Stream-URL -->
|
||||||
<string name="music_stream_url_label">Stream-URL</string>
|
<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>
|
<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_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>
|
<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 -->
|
<!-- Verhalten beim Wegpunkt -->
|
||||||
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
|
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
|
||||||
<string name="music_behavior_pause_resume">Pausieren und fortsetzen</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_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_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>
|
<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>
|
</resources>
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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" />
|
<style name="Theme.WaypointAudioGuide" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
</resources>
|
</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.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.enableJetifier=true
|
||||||
android.suppressUnsupportedCompileSdk=35
|
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.parallel=false
|
||||||
org.gradle.parallel=true
|
org.gradle.workers.max=1
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ navigationCompose = "2.8.2"
|
|||||||
osmdroid = "6.1.20"
|
osmdroid = "6.1.20"
|
||||||
ksp = "2.0.21-1.0.25"
|
ksp = "2.0.21-1.0.25"
|
||||||
media3 = "1.4.1"
|
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]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
media3-common = { group = "androidx.media3", name = "media3-common", 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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user