Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -0,0 +1,44 @@
package de.waypointaudio.data
import java.util.UUID
/**
* v2.5.12 — Ein einzelner Audio-Clip eines Wegpunkts.
*
* Mehrere Clips pro Wegpunkt werden vom Tour-Play in der durch [orderIndex]
* vorgegebenen Reihenfolge nacheinander abgespielt; deaktivierte Clips werden
* dabei übersprungen.
*
* @param id Stabile UUID. Wird bei Persistenz/Backup/Import beibehalten.
* @param title Anzeigename des Clips (z. B. "Einleitung", "Kapitel 2").
* Leer = Fallback auf [displayFileName] bzw. "Audio-Datei".
* @param uri Audio-URI (content:// oder file://). Leer = ungültiger Clip.
* @param displayFileName Lesbarer Dateiname für UI/Backup (z. B. der originale
* Dateiname aus dem Storage Access Framework).
* @param isActive Wenn false, wird der Clip beim Tour-Play übersprungen.
* Manuelles Einzel-Abspielen ist trotzdem möglich.
* @param isPrimary Markiert den "Haupt"-Clip eines Wegpunkts. Beim Migrieren
* legacy-Soundclips ist genau dieser Clip primary = true.
* Der primary-Clip wird in Listenansichten als Standard-
* Vorschau verwendet.
* @param orderIndex Stabile Sortierung. Niedrigere Werte werden zuerst
* abgespielt. Bei gleichen Werten entscheidet die UUID,
* damit die Reihenfolge deterministisch bleibt.
*/
data class AudioClip(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val uri: String = "",
val displayFileName: String = "",
val isActive: Boolean = true,
val isPrimary: Boolean = false,
val orderIndex: Int = 0
) {
/** Effektive Anzeige: Titel → Dateiname → "Audio-Datei". */
val effectiveTitle: String
get() = title.ifBlank { displayFileName.ifBlank { "Audio-Datei" } }
/** True, wenn der Clip eine nicht-leere URI besitzt. */
val hasAudio: Boolean
get() = uri.isNotBlank()
}
@@ -2,14 +2,23 @@ package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Globale Audio-Routing-Einstellungen für Live/PTT.
* Aufnahmeformat für PTT-Mitschnitt.
* M4A_AAC wird produktiv unterstützt; WAV ist in v2.1.0 absichtlich deaktiviert
* (siehe Handoff). Der enum-Wert bleibt vorhanden für künftige Erweiterung.
*/
enum class PttRecordingFormat { M4A_AAC, WAV }
/**
* Globale Audio-Routing- und PTT-Einstellungen für Live/PTT.
*
* Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach
* einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern.
@@ -18,22 +27,32 @@ import kotlinx.coroutines.flow.map
*
* @param selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard
* @param selectedOutputDeviceId ID des Ausgabegeräts, null = Systemstandard
* @param liveOutputEnabled Mikrofon-Loop auf Ausgabegerät (klassische PTT).
* Standard: true (rückwärtskompatibel zu v2.0.8).
* @param recordingEnabled PTT-Aufnahme aktiv. Standard: false.
* @param recordingFormat Aufnahmeformat. Standard: M4A_AAC.
*/
data class AudioRoutingSettings(
val selectedInputDeviceId: Int? = null,
val selectedOutputDeviceId: Int? = null
val selectedOutputDeviceId: Int? = null,
val liveOutputEnabled: Boolean = true,
val recordingEnabled: Boolean = false,
val recordingFormat: PttRecordingFormat = PttRecordingFormat.M4A_AAC
)
private val Context.audioRoutingDataStore by preferencesDataStore(name = "audio_routing_settings")
/**
* Persistenz-Schicht für Audio-Routing-Einstellungen via DataStore.
* Persistenz-Schicht für Audio-Routing- und PTT-Einstellungen via DataStore.
*/
class AudioRoutingStore(private val context: Context) {
private companion object {
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
val KEY_LIVE_OUTPUT = booleanPreferencesKey("ptt_live_output_enabled")
val KEY_RECORDING_ENABLED = booleanPreferencesKey("ptt_recording_enabled")
val KEY_RECORDING_FORMAT = stringPreferencesKey("ptt_recording_format")
// Sentinel: -1 means "use system default" (null cannot be stored as Int)
const val NO_DEVICE = -1
}
@@ -42,14 +61,22 @@ class AudioRoutingStore(private val context: Context) {
.map { prefs ->
AudioRoutingSettings(
selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
liveOutputEnabled = prefs[KEY_LIVE_OUTPUT] ?: true,
recordingEnabled = prefs[KEY_RECORDING_ENABLED] ?: false,
recordingFormat = prefs[KEY_RECORDING_FORMAT]
?.let { runCatching { PttRecordingFormat.valueOf(it) }.getOrNull() }
?: PttRecordingFormat.M4A_AAC
)
}
suspend fun save(settings: AudioRoutingSettings) {
context.audioRoutingDataStore.edit { prefs ->
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
prefs[KEY_LIVE_OUTPUT] = settings.liveOutputEnabled
prefs[KEY_RECORDING_ENABLED] = settings.recordingEnabled
prefs[KEY_RECORDING_FORMAT] = settings.recordingFormat.name
}
}
@@ -0,0 +1,37 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.13 — Speichert, für welchen versionCode der Nutzer den
* "Hintergrundwiedergabe absichern"-Hinweis bereits weggeklickt hat.
*
* Damit nervt der Hinweis nicht erneut bei jedem Tour-Start, taucht aber
* automatisch bei jedem neuen Update (steigt der versionCode) wieder auf,
* weil sich Empfehlungen mit jeder Version ändern können.
*/
private val Context.backgroundReliabilityDataStore by preferencesDataStore(
name = "background_reliability_settings"
)
class BackgroundReliabilityStore(private val context: Context) {
private companion object {
val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code")
}
/** Letzter ausgeblendeter versionCode; 0 = noch nie ausgeblendet. */
val dismissedVersionCode: Flow<Long> = context.backgroundReliabilityDataStore.data
.map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L }
suspend fun setDismissedVersionCode(code: Long) {
context.backgroundReliabilityDataStore.edit { prefs ->
prefs[KEY_DISMISSED_VERSION_CODE] = code
}
}
}
@@ -0,0 +1,40 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Persistente Einstellungen des Explorers (v2.2.3).
*
* Aktuell nur „GPS-Nachführung an/aus". Die Einstellung überlebt
* Neustarts und Navigation; eine manuelle Kartenverschiebung darf
* den Wert nicht automatisch wieder auf „an" setzen.
*/
data class ExplorerSettings(
val followLocation: Boolean = true,
)
private val Context.explorerDataStore by preferencesDataStore(name = "explorer_settings")
class ExplorerSettingsStore(private val context: Context) {
private companion object {
val KEY_FOLLOW = booleanPreferencesKey("follow_location")
}
val settings: Flow<ExplorerSettings> = context.explorerDataStore.data.map { prefs ->
ExplorerSettings(
followLocation = prefs[KEY_FOLLOW] ?: true
)
}
suspend fun setFollowLocation(enabled: Boolean) {
context.explorerDataStore.edit { prefs ->
prefs[KEY_FOLLOW] = enabled
}
}
}
@@ -0,0 +1,53 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.0 — Sichtbarkeitsoptionen für die optionalen Module der neuen
* Audio-Bibliothek-Hauptseite. PTT- und Atmo-Modul lassen sich
* unabhängig ein- und ausblenden ("Startseite anpassen").
*
* Default: beide Module sind sichtbar, damit bestehende Nutzer ihren
* bisherigen Zugang zu Live-PTT und Atmo nicht verlieren.
*/
data class HomeModulesSettings(
val showPtt: Boolean = true,
val showAtmo: Boolean = true,
val showMiniNowPlaying: Boolean = true
)
private val Context.homeModulesDataStore by preferencesDataStore(name = "home_modules_settings")
class HomeModulesSettingsStore(private val context: Context) {
private companion object {
val KEY_SHOW_PTT = booleanPreferencesKey("show_ptt")
val KEY_SHOW_ATMO = booleanPreferencesKey("show_atmo")
val KEY_SHOW_MINI_NOW = booleanPreferencesKey("show_mini_now")
}
val settings: Flow<HomeModulesSettings> = context.homeModulesDataStore.data.map { prefs ->
HomeModulesSettings(
showPtt = prefs[KEY_SHOW_PTT] ?: true,
showAtmo = prefs[KEY_SHOW_ATMO] ?: true,
showMiniNowPlaying = prefs[KEY_SHOW_MINI_NOW] ?: true
)
}
suspend fun setShowPtt(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_PTT] = value }
}
suspend fun setShowAtmo(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_ATMO] = value }
}
suspend fun setShowMiniNowPlaying(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_MINI_NOW] = value }
}
}
@@ -0,0 +1,143 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.MapTileIndex
/**
* Verfügbare Karten-Anbieter (Phase 1 von v2.0.0).
*
* Alle Provider werden via [org.osmdroid.tileprovider.tilesource.XYTileSource]
* angebunden — kein Library-Wechsel nötig. Die OSM-Attribution
* (siehe `osm_attribution_short`) bleibt für sämtliche Provider sichtbar.
*
* Hinweise zu Provider-spezifischen Bedingungen:
* - **CartoDB** Tiles dürfen für nicht-kommerzielle Nutzung frei verwendet
* werden (Attribution `© OpenStreetMap & CartoDB`).
* - **OpenTopoMap** ist für nicht-kommerzielle Nutzung frei.
* - **ESRI** Tiles sind für persönliche, nicht-kommerzielle Nutzung
* via Attribution `© Esri` erlaubt.
* - **Stadia Maps** verlangt einen API-Key für produktive Nutzung; der Default
* funktioniert für Tests; im Zweifel CartoDB/OSM verwenden.
*/
enum class MapProvider(
val key: String,
val displayNameRes: Int
) {
OSM("osm", de.waypointaudio.R.string.map_provider_osm),
CARTO_POSITRON("carto_positron", de.waypointaudio.R.string.map_provider_carto_positron),
CARTO_DARK("carto_dark", de.waypointaudio.R.string.map_provider_carto_dark),
OPEN_TOPO("open_topo", de.waypointaudio.R.string.map_provider_topo),
ESRI_SATELLITE("esri_satellite", de.waypointaudio.R.string.map_provider_esri_satellite),
ESRI_HYBRID("esri_hybrid", de.waypointaudio.R.string.map_provider_esri_hybrid),
STADIA_ALIDADE("stadia_alidade", de.waypointaudio.R.string.map_provider_stadia_alidade),
STADIA_OUTDOORS("stadia_outdoors", de.waypointaudio.R.string.map_provider_stadia_outdoors);
fun toTileSource(): ITileSource = when (this) {
OSM -> TileSourceFactory.MAPNIK
CARTO_POSITRON -> XYTileSource(
"CartoDB Positron", 1, 19, 256, ".png",
arrayOf(
"https://a.basemaps.cartocdn.com/light_all/",
"https://b.basemaps.cartocdn.com/light_all/",
"https://c.basemaps.cartocdn.com/light_all/"
),
"© OpenStreetMap-Mitwirkende, © CARTO"
)
CARTO_DARK -> XYTileSource(
"CartoDB Dark Matter", 1, 19, 256, ".png",
arrayOf(
"https://a.basemaps.cartocdn.com/dark_all/",
"https://b.basemaps.cartocdn.com/dark_all/",
"https://c.basemaps.cartocdn.com/dark_all/"
),
"© OpenStreetMap-Mitwirkende, © CARTO"
)
OPEN_TOPO -> XYTileSource(
"OpenTopoMap", 1, 17, 256, ".png",
arrayOf(
"https://a.tile.opentopomap.org/",
"https://b.tile.opentopomap.org/",
"https://c.tile.opentopomap.org/"
),
"Karte: © OpenTopoMap (CC-BY-SA)"
)
ESRI_SATELLITE -> object : XYTileSource(
"ESRI World Imagery", 1, 19, 256, "",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/"),
"Tiles © Esri"
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl +
MapTileIndex.getZoom(pMapTileIndex) + "/" +
MapTileIndex.getY(pMapTileIndex) + "/" +
MapTileIndex.getX(pMapTileIndex)
}
}
ESRI_HYBRID -> object : XYTileSource(
"ESRI Hybrid Reference", 1, 19, 256, "",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/"),
"Tiles © Esri"
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl +
MapTileIndex.getZoom(pMapTileIndex) + "/" +
MapTileIndex.getY(pMapTileIndex) + "/" +
MapTileIndex.getX(pMapTileIndex)
}
}
STADIA_ALIDADE -> XYTileSource(
"Stadia Alidade Smooth", 1, 20, 256, ".png",
arrayOf("https://tiles.stadiamaps.com/tiles/alidade_smooth/"),
"© Stadia Maps, © OpenStreetMap-Mitwirkende"
)
STADIA_OUTDOORS -> XYTileSource(
"Stadia Outdoors", 1, 20, 256, ".png",
arrayOf("https://tiles.stadiamaps.com/tiles/outdoors/"),
"© Stadia Maps, © OpenStreetMap-Mitwirkende"
)
}
companion object {
val DEFAULT = OSM
fun fromKey(key: String?): MapProvider =
values().firstOrNull { it.key == key } ?: DEFAULT
}
}
private val Context.mapProviderDataStore by preferencesDataStore(name = "map_provider_settings")
/**
* Persistiert den ausgewählten Karten-Provider app-weit.
*
* Hinweis: Die Auswahl ist bewusst app-weit (nicht pro Tour) gehalten Touren
* verwalten bisher keine kartenspezifischen Einstellungen, und der Wechsel des
* Anbieters ist im Karten-Menü jederzeit erreichbar.
*/
class MapProviderStore(private val context: Context) {
private companion object {
val KEY_PROVIDER = stringPreferencesKey("selected_map_provider")
}
val provider: Flow<MapProvider> = context.mapProviderDataStore.data.map { prefs ->
MapProvider.fromKey(prefs[KEY_PROVIDER])
}
suspend fun currentProvider(): MapProvider = provider.first()
suspend fun setProvider(provider: MapProvider) {
context.mapProviderDataStore.edit { prefs ->
prefs[KEY_PROVIDER] = provider.key
}
}
}
@@ -0,0 +1,241 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Basis-Kartenstil — v2.5.38 Explorer Map Styles.
*
* Alle Stile nutzen OpenFreeMap (https://openfreemap.org) für Vektor-Tiles,
* kostenlos ohne API-Key, ohne eigenen Server. Attribution: © OpenFreeMap,
* © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende.
*
* MODERN_2D — Positron: heller, reduzierter Vektorstil. Ideal als
* neutraler Hintergrund für Wegpunkte/Routen.
* URL: https://tiles.openfreemap.org/styles/positron
*
* DARK — Dark Matter: dunkler Vektorstil.
* URL: https://tiles.openfreemap.org/styles/dark
*
* OUTDOOR — Fiord/Outdoor: Liberty-Vektorstil mit Outdoor-/Geländecharakter.
* URL: https://tiles.openfreemap.org/styles/liberty
* (Liberty ist der vollständigste, lebendigste OpenFreeMap-Style
* mit deutlicher Grün-/Geländeanmutung.)
*
* CITY_3D — 3D Stadt: Positron-Basis + MapLibre fill-extrusion für
* Gebäude-Layer (building). Wenn kein building-Layer in der
* Style-Source, automatischer 2D-Fallback ohne Crash.
* URL: https://tiles.openfreemap.org/styles/positron
* + programmatische fill-extrusion nachträglich per addLayer.
*
* CLASSIC — Klassisch: OSM-Raster-Tiles (tile.openstreetmap.org),
* raster-basierter Fallback. Kein Vektor.
*
* SATELLITE — Legacy aus v2.4.x. Wird auf MODERN_2D migriert.
* Satellit ist ab v2.5.37 nicht mehr im UI angeboten.
* TERRAIN_3D — Legacy aus v2.4.x. Wird auf CITY_3D migriert.
* TOPO — Legacy aus v2.4.x. Wird auf OUTDOOR migriert.
* PERSPECTIVE — Legacy aus v2.3.x. Wird auf CITY_3D migriert.
*/
enum class MapBaseStyle(val key: String) {
MODERN_2D("modern_2d"),
DARK("dark"),
OUTDOOR("outdoor"),
CITY_3D("city_3d"),
CLASSIC("classic"),
// Legacy — für Rückwärtskompatibilität gespeicherter Settings:
SATELLITE("satellite"),
TERRAIN_3D("terrain_3d"),
TOPO("topo"),
PERSPECTIVE("perspective");
companion object {
val DEFAULT = MODERN_2D
fun fromKey(key: String?): MapBaseStyle {
return when (key) {
"modern_2d" -> MODERN_2D
"dark" -> DARK
"outdoor" -> OUTDOOR
"city_3d" -> CITY_3D
"classic" -> CLASSIC
// Legacy migration:
"satellite" -> MODERN_2D
"terrain_3d" -> CITY_3D
"topo" -> OUTDOOR
"perspective"-> CITY_3D
else -> DEFAULT
}
}
}
}
/**
* POI-Kategorie-Auswahl — v2.5.38.
*
* Jede Kategorie schaltet eine Gruppe von MapLibre-Symbol-Layern sichtbar/unsichtbar.
* Wenn der Style keinen passenden Layer enthält, passiert nichts (kein Crash).
*
* Kategorien:
* SIGHTS — Sehenswürdigkeiten / Tourismus (tourism, historic, amenity:place_of_worship)
* INFRASTRUCTURE — Infrastruktur (hospital, pharmacy, police, post, atm, bank, fuel)
* GASTRONOMY — Gastronomie (restaurant, cafe, fast_food, bar, pub, ice_cream)
* MOBILITY — Mobilität (bus_stop, train_station, parking, bicycle, car_sharing)
*/
enum class PoiCategory(val key: String) {
SIGHTS("sights"),
INFRASTRUCTURE("infrastructure"),
GASTRONOMY("gastronomy"),
MOBILITY("mobility");
companion object {
fun fromKey(key: String?): PoiCategory? = values().firstOrNull { it.key == key }
}
}
/**
* Persistierte POI-Sichtbarkeit je Kategorie — v2.5.44.
* Default: Alle vier Hauptkategorien aktiv, damit der Nutzer sofort
* alle POI-Typen sieht (Fix: vorher nur Sehenswürdigkeiten sichtbar).
* Namen-Labels bleiben standardmäßig aus (Performance/Übersichtlichkeit).
*/
data class PoiLayerSettings(
val showSights: Boolean = true,
val showInfrastructure: Boolean = true,
val showGastronomy: Boolean = true,
val showMobility: Boolean = true,
// v2.5.42 — Namen-Labels auf der Karte standardmäßig deaktiviert
val showPoiNames: Boolean = false,
)
/**
* Gespeicherte Karten-Stil-Einstellungen — v2.5.38.
*
* @param baseStyle gewählter Basis-Stil (wird persistent gespeichert)
* @param showAudioLength ob die visuelle Audio-/Trigger-Länge als
* Ring/Indikator je Wegpunkt eingeblendet wird.
* @param perspectiveTilt Kamera-Pitch für CITY_3D (Grad, 0..60).
* @param showAudioLines Clip-Längen als projizierte Liniensegmente.
* @param routingEndpoint optionaler OSRM-Routing-Endpoint.
* @param poiLayers POI-Kategorien-Sichtbarkeit (persistent).
*
* Legacy-Felder (customSatelliteUrl, customTopoUrl, customTerrainDemUrl) werden
* weiterhin gelesen, aber nicht mehr in der UI angeboten — für Rückwärts-
* kompatibilität mit gespeicherten Preferences aus v2.4.x.
*/
data class MapStyleSettings(
val baseStyle: MapBaseStyle = MapBaseStyle.DEFAULT,
val customSatelliteUrl: String = "", // legacy, wird ignoriert
val customTopoUrl: String = "", // legacy, wird ignoriert
val customTerrainDemUrl: String = "", // legacy, wird ignoriert
val showAudioLength: Boolean = true,
val perspectiveTilt: Int = 35,
val showAudioLines: Boolean = true,
val routingEndpoint: String = "",
val poiLayers: PoiLayerSettings = PoiLayerSettings(),
)
private val Context.mapStyleDataStore by preferencesDataStore(name = "map_style_settings")
class MapStyleStore(private val context: Context) {
private companion object {
val KEY_BASE_STYLE = stringPreferencesKey("base_style")
val KEY_SAT_URL = stringPreferencesKey("custom_satellite_url")
val KEY_TOPO_URL = stringPreferencesKey("custom_topo_url")
val KEY_TERRAIN_DEM_URL = stringPreferencesKey("custom_terrain_dem_url")
val KEY_SHOW_AUDIO_LEN = booleanPreferencesKey("show_audio_length")
val KEY_PERSPECTIVE_TILT = stringPreferencesKey("perspective_tilt")
val KEY_SHOW_AUDIO_LINES = booleanPreferencesKey("show_audio_lines")
val KEY_ROUTING_ENDPOINT = stringPreferencesKey("routing_endpoint")
// v2.5.38 — POI-Layer-Persistenz
val KEY_POI_SIGHTS = booleanPreferencesKey("poi_sights")
val KEY_POI_INFRASTRUCTURE = booleanPreferencesKey("poi_infrastructure")
val KEY_POI_GASTRONOMY = booleanPreferencesKey("poi_gastronomy")
val KEY_POI_MOBILITY = booleanPreferencesKey("poi_mobility")
// v2.5.42
val KEY_POI_SHOW_NAMES = booleanPreferencesKey("poi_show_names")
}
val settings: Flow<MapStyleSettings> = context.mapStyleDataStore.data.map { prefs ->
MapStyleSettings(
baseStyle = MapBaseStyle.fromKey(prefs[KEY_BASE_STYLE]),
customSatelliteUrl = prefs[KEY_SAT_URL].orEmpty(),
customTopoUrl = prefs[KEY_TOPO_URL].orEmpty(),
customTerrainDemUrl = prefs[KEY_TERRAIN_DEM_URL].orEmpty(),
showAudioLength = prefs[KEY_SHOW_AUDIO_LEN] ?: true,
perspectiveTilt = prefs[KEY_PERSPECTIVE_TILT]?.toIntOrNull()?.coerceIn(0, 60) ?: 35,
showAudioLines = prefs[KEY_SHOW_AUDIO_LINES] ?: true,
routingEndpoint = prefs[KEY_ROUTING_ENDPOINT].orEmpty(),
poiLayers = PoiLayerSettings(
showSights = prefs[KEY_POI_SIGHTS] ?: true,
// v2.5.44: Default auf true geändert — Nutzer ohne gespeicherte
// Einstellung bekommen jetzt alle Kategorien aktiv
showInfrastructure = prefs[KEY_POI_INFRASTRUCTURE] ?: true,
showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true,
showMobility = prefs[KEY_POI_MOBILITY] ?: true,
showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false,
)
)
}
suspend fun setBaseStyle(style: MapBaseStyle) {
context.mapStyleDataStore.edit { it[KEY_BASE_STYLE] = style.key }
}
suspend fun setShowAudioLength(enabled: Boolean) {
context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LEN] = enabled }
}
suspend fun setPerspectiveTilt(tilt: Int) {
context.mapStyleDataStore.edit {
it[KEY_PERSPECTIVE_TILT] = tilt.coerceIn(0, 60).toString()
}
}
suspend fun setShowAudioLines(enabled: Boolean) {
context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LINES] = enabled }
}
// v2.5.38 — POI-Layer-Persistenz
suspend fun setPoiLayers(poi: PoiLayerSettings) {
context.mapStyleDataStore.edit {
it[KEY_POI_SIGHTS] = poi.showSights
it[KEY_POI_INFRASTRUCTURE] = poi.showInfrastructure
it[KEY_POI_GASTRONOMY] = poi.showGastronomy
it[KEY_POI_MOBILITY] = poi.showMobility
it[KEY_POI_SHOW_NAMES] = poi.showPoiNames
}
}
// v2.5.37 — Legacy-Stubs für Aufrufstellen aus v2.4.x die
// noch customSatelliteUrl / customTopoUrl / customTerrainDemUrl schreiben.
// Diese Werte werden weiterhin gelesen (Preferences bleiben),
// aber im UI nicht mehr angeboten und vom Style-Builder ignoriert.
suspend fun setCustomSatelliteUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_SAT_URL] = url.trim() }
}
suspend fun setCustomTopoUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_TOPO_URL] = url.trim() }
}
suspend fun setCustomTerrainDemUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_TERRAIN_DEM_URL] = url.trim() }
}
/**
* Speichert den Routing-Endpunkt — v2.4.4: nutzt
* [de.waypointaudio.util.OsrmRoutingClient.normalizeBase], sodass der
* Nutzer sowohl die reine Basis-URL (`https://router.project-osrm.org`)
* als auch die vollständige `/route/v1/driving`-Variante speichern darf.
* Intern landet in den Preferences immer die normalisierte Basis-URL,
* was Doppelprefixe bei der eigentlichen Request-URL ausschließt.
*/
suspend fun setRoutingEndpoint(url: String) {
val normalized = de.waypointaudio.util.OsrmRoutingClient.normalizeBase(url)
context.mapStyleDataStore.edit { it[KEY_ROUTING_ENDPOINT] = normalized }
}
}
@@ -0,0 +1,86 @@
package de.waypointaudio.data
/**
* v2.5.14 — Flach gewickelter Playback-Eintrag.
*
* Ein [PlayableItem] beschreibt genau eine abspielbare Audio-Quelle in der
* Tour-Reihenfolge. Die Sequenz wird aus den Wegpunkten der aktuell sichtbaren
* Tour aufgebaut: pro Wegpunkt werden alle aktiven Clips in der durch
* [AudioClip.orderIndex] vorgegebenen Reihenfolge expandiert. Diese flache
* Liste ist die *einzige Wahrheit* für die manuelle Wiedergabe, die
* Vor/Zurück-Navigation im Clip-Detail-Player und die Anzeige "Clip 2 von 3".
*
* Wichtig:
* - [waypointIndex] und [waypointTotal] referenzieren die Position des
* Wegpunkts in der abspielbaren Tour-Liste (1-basiert, nicht der Index in
* der DataStore-Reihenfolge).
* - [clipIndex] und [clipTotal] referenzieren die Position des Clips
* innerhalb seines Wegpunkts (1-basiert).
* - [compoundId] = "${waypointId}#${clipId}" ist der ID-Schlüssel, den der
* [de.waypointaudio.service.ManualAudioPlayer] im Single-Mode verwendet.
* Damit erkennt der Player "schon spielend", wenn ein zweites Tippen auf
* denselben Clip in Pause/Resume münden soll.
*/
data class PlayableItem(
val waypointId: String,
val clipId: String,
val waypointIndex: Int,
val waypointTotal: Int,
val clipIndex: Int,
val clipTotal: Int,
val waypointName: String,
val clipTitle: String,
val uri: String,
) {
val compoundId: String
get() = "$waypointId#$clipId"
/**
* Anzeigename für Notification/Mini-Player. Bei mehreren Clips wird das
* Suffix "(2/3)" angehängt, damit der Nutzer auch ohne offenes Detail-
* Sheet erkennt, an welcher Stelle er sich befindet.
*/
val displayTitle: String
get() {
val wp = waypointName.ifBlank { "Wegpunkt" }
val clip = clipTitle.takeIf { it.isNotBlank() }
return when {
clipTotal <= 1 -> wp
clip == null -> "$wp ($clipIndex/$clipTotal)"
else -> "$wp · $clip ($clipIndex/$clipTotal)"
}
}
}
/**
* v2.5.14 — Erzeugt die flache Playback-Sequenz für eine Liste von Wegpunkten
* (typischerweise: alle aktiven, abspielbaren Wegpunkte der aktuellen Tour).
*
* Wegpunkte ohne aktive Clips werden übersprungen. Innerhalb eines Wegpunkts
* werden die Clips in der durch [Waypoint.activeClips] festgelegten Sortierung
* (orderIndex, dann id) expandiert.
*/
fun List<Waypoint>.toPlayableSequence(): List<PlayableItem> {
val waypointsWithClips = this
.map { wp -> wp to wp.activeClips() }
.filter { it.second.isNotEmpty() }
val waypointTotal = waypointsWithClips.size
val items = mutableListOf<PlayableItem>()
waypointsWithClips.forEachIndexed { wpIdx, (wp, clips) ->
val clipTotal = clips.size
clips.forEachIndexed { clipIdx, clip ->
items += PlayableItem(
waypointId = wp.id,
clipId = clip.id,
waypointIndex = wpIdx + 1,
waypointTotal = waypointTotal,
clipIndex = clipIdx + 1,
clipTotal = clipTotal,
waypointName = wp.name,
clipTitle = clip.effectiveTitle,
uri = clip.uri,
)
}
}
return items
}
@@ -0,0 +1,37 @@
package de.waypointaudio.data
/**
* v2.5.14 — Schlanker Fortschritts-Snapshot des manuellen Players.
*
* Wird vom ViewModel periodisch (Tick ~500 ms) aktualisiert, solange Audio
* läuft, und vom Clip-Detail-Player sowie der kompakten Wiedergabeleiste
* konsumiert.
*
* @property positionMs Verstrichene Position in ms (>= 0).
* @property durationMs Gesamtdauer in ms; -1 wenn unbekannt (Streams,
* noch nicht geprepared). UI sollte dann "--:--" zeigen.
* @property isPlaying True, solange tatsächlich abgespielt wird (nicht
* pausiert/gestoppt). Bequemes Flag für die Hülle.
*/
data class PlaybackProgress(
val positionMs: Long = 0L,
val durationMs: Long = -1L,
val isPlaying: Boolean = false,
) {
/** Verbleibende Restzeit in ms, oder -1 wenn unbekannt. */
val remainingMs: Long
get() = if (durationMs > 0) (durationMs - positionMs).coerceAtLeast(0L) else -1L
companion object {
val EMPTY = PlaybackProgress()
}
}
/** Formatiert eine Millisekundenzahl als "m:ss" oder "--:--" wenn unbekannt. */
fun formatRemaining(remainingMs: Long): String {
if (remainingMs < 0) return "--:--"
val totalSeconds = (remainingMs + 500) / 1000
val m = totalSeconds / 60
val s = totalSeconds % 60
return "%d:%02d".format(m, s)
}
@@ -0,0 +1,118 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.UUID
/**
* Metadaten einer PTT-Aufnahme.
*
* Die Audiodatei selbst liegt in [filePath] (App-spezifisches Verzeichnis,
* Unterordner `recordings/`). [uri] ist die file://-Repräsentation, die für
* Wegpunkt-Zuweisung und Wiedergabe verwendet wird.
*
* @param id UUID der Aufnahme
* @param displayName Anzeigename (Datei ohne Erweiterung, vom Nutzer ggf. überschreibbar)
* @param filePath Absoluter Pfad zur Audiodatei
* @param uri file://-URI für Wiedergabe und Wegpunkt-Zuweisung
* @param format Dateiformat ("m4a", "wav")
* @param createdAt Erstellzeitpunkt in Unix-Millisekunden
* @param durationMs Aufnahmedauer in Millisekunden (0 wenn unbekannt)
* @param latitude Optionaler Breitengrad der Aufnahmestelle
* @param longitude Optionaler Längengrad der Aufnahmestelle
*/
data class PttRecording(
val id: String = UUID.randomUUID().toString(),
val displayName: String,
val filePath: String,
val uri: String,
val format: String,
val createdAt: Long,
val durationMs: Long = 0L,
val latitude: Double? = null,
val longitude: Double? = null
)
private val Context.pttRecordingsDataStore by preferencesDataStore(name = "ptt_recordings")
/**
* Persistente Bibliothek aller PTT-Aufnahmen (Metadaten).
*
* Die Audiodateien liegen im app-spezifischen Verzeichnis
* `<filesDir>/recordings/`. Dieser Store hält ausschließlich die Metadaten.
*/
class PttRecordingStore(private val context: Context) {
private companion object {
val KEY_ENTRIES = stringPreferencesKey("recordings_json")
}
private val gson = Gson()
private val listType = object : TypeToken<List<PttRecording>>() {}.type
/** Reaktiver Flow aller Aufnahme-Metadaten (neueste zuerst, sortiert nach createdAt desc). */
val recordings: Flow<List<PttRecording>> = context.pttRecordingsDataStore.data
.map { prefs ->
val raw = prefs[KEY_ENTRIES]
if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
}
.map { list -> list.sortedByDescending { it.createdAt } }
/** Liefert die aktuelle Liste einmalig (für innerhalb-Transaktionen). */
private suspend fun readOnce(): List<PttRecording> {
var result: List<PttRecording> = emptyList()
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
result = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
}
return result
}
suspend fun add(entry: PttRecording) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
val updated = (list + entry).distinctBy { it.id }
prefs[KEY_ENTRIES] = gson.toJson(updated)
}
}
suspend fun delete(id: String) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
prefs[KEY_ENTRIES] = gson.toJson(list.filterNot { it.id == id })
}
}
suspend fun rename(id: String, newName: String) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
prefs[KEY_ENTRIES] = gson.toJson(list.map {
if (it.id == id) it.copy(displayName = newName) else it
})
}
}
}
@@ -0,0 +1,41 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.9 — Persistiert die vom Nutzer akzeptierte Version der
* Nutzungsbedingungen. Wird beim ersten App-Start abgefragt; eine
* neue Terms-Version (z. B. „2.5.11-terms-1") führt zur erneuten
* Anzeige des Zustimmungsdialogs.
*/
private val Context.termsDataStore by preferencesDataStore(name = "terms_settings")
object TermsVersions {
/**
* Aktuell gültige Terms-Version. Beim Bump dieser Konstante muss
* der In-App-Dialog beim nächsten Start neu zustimmen lassen.
*/
const val CURRENT = "2.5.11-terms-1"
}
class TermsStore(private val context: Context) {
private companion object {
val KEY_ACCEPTED_VERSION = stringPreferencesKey("accepted_terms_version")
}
/** Zuletzt akzeptierte Terms-Version (Leerstring = noch nie akzeptiert). */
val acceptedVersion: Flow<String> = context.termsDataStore.data
.map { it[KEY_ACCEPTED_VERSION] ?: "" }
suspend fun setAccepted(version: String) {
context.termsDataStore.edit { prefs ->
prefs[KEY_ACCEPTED_VERSION] = version
}
}
}
@@ -33,13 +33,27 @@ data class PlaylistItem(
val displayName: String
)
/**
* Ein einzelner Stream-Eintrag (z. B. Internetradio-Sender) in der Stream-Playlist
* einer Tour.
*
* @param url HTTP/HTTPS-Direkt-URL zum Stream.
* @param name Optionaler Anzeigename. Wenn leer, wird die URL/der Host angezeigt.
*/
data class StreamEntry(
val url: String,
val name: String = ""
)
/**
* Begleitmusik-Einstellungen für eine Tour.
*
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
* @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher.
* @param streamUrl Direkte HTTP/HTTPS-Stream-URL (z. B. Internetradio).
* @param streamPlaylist Liste von Stream-Einträgen (ab v2.0.8 Stream-Playlist statt einzelner URL).
* @param streamUrl Legacy-Feld für einzelne Stream-URL (Backward-Compat bei Bedarf in
* streamPlaylist migriert; siehe [migrated]).
* @param behavior Verhalten wenn Wegpunkt-Audio abgespielt wird.
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
* @param duckVolume Lautstärke (0.01.0) während des Duck-Modus.
@@ -51,10 +65,37 @@ data class TourAudioSettings(
val enabled: Boolean = false,
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
val localPlaylist: List<PlaylistItem> = emptyList(),
val streamPlaylist: List<StreamEntry> = emptyList(),
val streamUrl: String? = null,
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
val fadeDurationMs: Long = 1500L,
val duckVolume: Float = 0.25f,
val shuffle: Boolean = false,
val autoStartAfterWaypoint: Boolean = false
)
) {
/**
* Liefert die effektive Stream-Playlist wenn keine streamPlaylist gespeichert ist,
* aber das Legacy-streamUrl gesetzt ist, wird daraus ein einzelner Eintrag erzeugt.
*/
val effectiveStreamPlaylist: List<StreamEntry>
get() = when {
streamPlaylist.isNotEmpty() -> streamPlaylist
!streamUrl.isNullOrBlank() -> listOf(StreamEntry(url = streamUrl, name = ""))
else -> emptyList()
}
/**
* Erzeugt eine Kopie mit migrierter Stream-Playlist:
* falls streamPlaylist leer ist und streamUrl gesetzt war, wird streamUrl in
* die Stream-Playlist überführt und das Legacy-Feld geleert.
*/
fun migrated(): TourAudioSettings {
if (streamPlaylist.isEmpty() && !streamUrl.isNullOrBlank()) {
return copy(
streamPlaylist = listOf(StreamEntry(url = streamUrl, name = "")),
streamUrl = null
)
}
return this
}
}
@@ -0,0 +1,68 @@
package de.waypointaudio.data
/**
* v2.5.5 — Persistierbares Tour-Cover.
*
* Statt eines externen Bild-Pickers wählt der Nutzer aus einem festen,
* deterministischen Designkatalog (Icon + Farbverlauf). Das hält den
* Datenpfad stabil, vermeidet Berechtigungs-/Lifecycle-Themen und lässt
* sich verlustfrei via JSON/Backup transportieren.
*
* Persistenz:
* - JSON-Map `tourName → TourCover` im [WaypointStore] unter eigenem Key.
* - Bestehende Backups ohne diesen Key bleiben gültig (Default leer).
* - Backup-Export/Import nehmen die Map mit (siehe BackupExportManager).
*
* UI:
* - Wenn für eine Tour kein Cover gewählt ist, behält die Hero-Karte ihre
* bisherige deterministische Optik (Akzentfarbe + Icon-Heuristik aus dem
* Tournamen). „Cover ändern…“ ersetzt das durch die Auswahl.
*/
enum class TourCoverIcon {
HEADPHONES,
MUSIC_NOTE,
HIKING,
PARK,
CITY,
BIKE,
WAVES,
EXPLORE,
LANDSCAPE,
CAMPAIGN,
NIGHTLIFE,
THEATER_COMEDY,
}
/**
* Auswählbare Farbschemen für das Cover. Werte sind Identifier; die konkrete
* Farbabbildung erfolgt in der UI (siehe TourCoverPalette in WaypointListScreen),
* damit Theme-Anpassungen ohne Daten-Migration möglich bleiben.
*/
enum class TourCoverPalette {
TEAL,
OCEAN,
SUNSET,
FOREST,
BERRY,
DUSK,
SAND,
MIDNIGHT,
}
/**
* Vollständiges, persistiertes Cover einer Tour.
*
* Bewusst minimal: nur Icon + Palette. Texte/Beschriftungen bleiben aus dem
* Tournamen abgeleitet. Damit ist die Auswahl klein, robust und schnell.
*/
data class TourCover(
val icon: TourCoverIcon = TourCoverIcon.HEADPHONES,
val palette: TourCoverPalette = TourCoverPalette.TEAL,
/**
* v2.5.7 — Optionaler Pfad zu einem app-internen Cover-Bild. Liegt ein Bild
* vor, dominiert es Hero/Card; sonst wird Icon+Palette gerendert. Wird beim
* Speichern auf eine app-interne Datei kopiert (siehe TourCoverImageStore).
* Null/leer = kein Bild gewählt → Fallback auf Icon+Palette.
*/
val imageUri: String? = null,
)
@@ -34,7 +34,7 @@ class TourMusicStore(private val context: Context) {
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
runCatching {
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap()
raw?.mapValues { (_, v) -> v.toDomain().migrated() } ?: emptyMap()
}.getOrDefault(emptyMap())
}
@@ -81,10 +81,21 @@ internal data class PlaylistItemJson(
}
}
internal data class StreamEntryJson(
val url: String = "",
val name: String = ""
) {
fun toDomain() = StreamEntry(url = url, name = name)
companion object {
fun fromDomain(entry: StreamEntry) = StreamEntryJson(entry.url, entry.name)
}
}
internal data class TourAudioSettingsJson(
val enabled: Boolean = false,
val sourceType: String = "LOCAL_PLAYLIST",
val localPlaylist: List<PlaylistItemJson> = emptyList(),
val streamPlaylist: List<StreamEntryJson>? = null,
val streamUrl: String? = null,
val behavior: String = "PAUSE_RESUME",
val fadeDurationMs: Long = 1500L,
@@ -97,6 +108,7 @@ internal data class TourAudioSettingsJson(
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
localPlaylist = localPlaylist.map { it.toDomain() },
streamPlaylist = streamPlaylist?.map { it.toDomain() } ?: emptyList(),
streamUrl = streamUrl,
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
@@ -111,6 +123,7 @@ internal data class TourAudioSettingsJson(
enabled = s.enabled,
sourceType = s.sourceType.name,
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
streamPlaylist = s.streamPlaylist.map { StreamEntryJson.fromDomain(it) },
streamUrl = s.streamUrl,
behavior = s.behavior.name,
fadeDurationMs = s.fadeDurationMs,
@@ -0,0 +1,94 @@
package de.waypointaudio.data
import java.util.UUID
/**
* v2.4.6 — Tourroute (an die Tour gebunden, nicht an einen einzelnen Wegpunkt).
*
* Architekturentscheidung (Nutzerabstimmung):
* - Eine [Tour] kann dauerhaft eine zuvor geplante/berechnete Route besitzen.
* - Wegpunkte bleiben ein wiederverwendbarer Baustein sie tragen keine
* Route. Dadurch lassen sich Wegpunkte später in andere Touren kopieren,
* ohne dass dort eine fremde Route mitgezogen wird.
* - Die [polyline] ist die führende Geometrie. Clipabschnitte und
* Längenlinien projizieren sich darauf — siehe Priorität in
* [de.waypointaudio.ui.ExplorerScreenMapLibre].
*
* Persistenz:
* - Gespeichert im [WaypointStore] als JSON-Map `tourName → TourRoute`.
* - Bestehende Backups/JSON ohne dieses Feld bleiben gültig (Default leer).
* - Backup-Export/Import nehmen Tourrouten mit (siehe BackupExportManager).
*/
enum class RouteSourceKind {
/** Route stammt aus einer OSRM-Berechnung. */
OSRM,
/** Reine Planlinie (Luftlinie zwischen den Stops). */
PLANLINE,
/** Aus einem GPS-Track / Draft übernommen (zukünftig). */
TRACK,
/** Aus einem importierten Backup übernommen. */
IMPORTED,
/**
* v2.5.3 — Manuell auf der Karte gezeichnete Route ("Route zeichnen").
* Die Polyline entsteht direkt durch Antippen der Karte, ohne OSRM-Aufruf.
* Inhaltlich gleich zu OSRM/PLANLINE (führende Geometrie ist [TourRoute.polyline]),
* aber als getrennte Quelle markiert, damit die UI klar zwischen
* "berechnet" und "gezeichnet" unterscheiden kann.
*/
MANUAL,
}
/**
* Ein Wegpunkt der Route — Start, Via oder Ziel — mit Label und Quellhinweis.
* Bewusst eigene Struktur (nicht [Waypoint]), damit Tourrouten unabhängig
* von der globalen Wegpunktliste persistieren.
*/
data class TourRouteStop(
val latitude: Double,
val longitude: Double,
val label: String = "",
val sourceHint: String = "",
)
/** Ein Lat/Lon-Punkt der Geometrie. */
data class TourRoutePoint(
val lat: Double,
val lon: Double,
)
/**
* Vollständige Tourroute.
*
* @param id Stabile ID (UUID).
* @param start Startpunkt (optional, kann bei reinen Geometrie-Importen fehlen).
* @param destination Zielpunkt (optional, siehe oben).
* @param via Geordnete Liste der Via-Stopps.
* @param polyline Routengeometrie als Lat/Lon-Punkte (führende Quelle).
* @param profile Routingprofil (z. B. "driving"). Leer = unspezifiziert.
* @param source Quellkategorie (OSRM/Planline/Track/Imported).
* @param endpoint Routing-Endpunkt oder Quellbeschreibung (Backend-URL, „Planlinie", …).
* @param distanceMeters Distanz in Metern (null wenn unbekannt, z. B. Planline).
* @param durationSeconds Dauer in Sekunden (null wenn unbekannt).
* @param createdAtMillis Anlegezeitpunkt (Unix ms).
* @param updatedAtMillis Letzte Aktualisierung (Unix ms).
*/
data class TourRoute(
val id: String = UUID.randomUUID().toString(),
val start: TourRouteStop? = null,
val destination: TourRouteStop? = null,
val via: List<TourRouteStop> = emptyList(),
val polyline: List<TourRoutePoint> = emptyList(),
val profile: String = "driving",
val source: RouteSourceKind = RouteSourceKind.OSRM,
val endpoint: String = "",
val distanceMeters: Double? = null,
val durationSeconds: Double? = null,
val createdAtMillis: Long = System.currentTimeMillis(),
val updatedAtMillis: Long = System.currentTimeMillis(),
) {
/** Anzahl gespeicherter Geometriepunkte. Nützlich für Status/UI. */
val pointCount: Int get() = polyline.size
/** True wenn die Geometrie als Linie nutzbar ist. */
val hasGeometry: Boolean get() = polyline.size >= 2
}
@@ -0,0 +1,38 @@
package de.waypointaudio.data
import java.util.UUID
/**
* Ein eigenständig archivierter Track-Entwurf (Draft) — entsteht durch
* eine GPS-Aufzeichnung und kann später unabhängig weiterer Aufzeichnungen
* editiert und in eine Tour als Wegpunkte importiert werden.
*
* Wichtig: Jeder Draft hat eine eigene UUID. Neue Aufzeichnungen erzeugen
* NEUE Drafts und überschreiben keine vorhandenen.
*
* @param id Eindeutige ID
* @param name Anzeigename (vorgegeben aus Tour + Datum, vom Nutzer änderbar)
* @param sourceTourName Name der Tour, in der die Aufzeichnung lief
* @param createdAt Unix-Millisekunden des Anlegens
* @param updatedAt Unix-Millisekunden der letzten Bearbeitung
* @param points Geordnete Liste der Trackpunkte (vollständig)
* @param markedIndexes Indizes (in [points]) der explizit als künftiger
* Wegpunkt markierten Punkte. Leer = nichts markiert.
* @param notes Optionale Notiz des Nutzers
*/
data class TrackDraft(
val id: String = UUID.randomUUID().toString(),
val name: String = "",
val sourceTourName: String = Waypoint.DEFAULT_TOUR_NAME,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val points: List<GpsTrackPoint> = emptyList(),
val markedIndexes: List<Int> = emptyList(),
val notes: String = ""
) {
/** Punktanzahl. */
val pointCount: Int get() = points.size
/** Anzahl markierter (für Import vorgesehener) Punkte. */
val markedCount: Int get() = markedIndexes.size
}
@@ -0,0 +1,32 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Speichert den letzten versionCode, für den der Versionshinweis-Dialog
* vom Nutzer ausgeblendet wurde. Steigt der versionCode, erscheint der
* Dialog automatisch erneut auch wenn er zuvor deaktiviert wurde.
*/
private val Context.versionNoticeDataStore by preferencesDataStore(name = "version_notice_settings")
class VersionNoticeStore(private val context: Context) {
private companion object {
val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code")
}
/** Letzter ausgeblendeter versionCode, oder 0 falls noch nie ausgeblendet. */
val dismissedVersionCode: Flow<Long> = context.versionNoticeDataStore.data
.map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L }
suspend fun setDismissedVersionCode(code: Long) {
context.versionNoticeDataStore.edit { prefs ->
prefs[KEY_DISMISSED_VERSION_CODE] = code
}
}
}
@@ -0,0 +1,64 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
/**
* Modus, wann das Display aktiv gehalten werden soll.
*/
enum class WakeLockMode(val storageKey: String, val germanLabel: String) {
NEVER("never", "Nie"),
APP_FOREGROUND("foreground", "Immer wenn App im Vordergrund"),
ACTIVE_TOUR("active_tour", "Nur bei aktiver Tour/GPS");
companion object {
fun fromKey(key: String?): WakeLockMode = when (key) {
APP_FOREGROUND.storageKey -> APP_FOREGROUND
ACTIVE_TOUR.storageKey -> ACTIVE_TOUR
else -> NEVER
}
}
}
data class WakeLockSettings(
val mode: WakeLockMode = WakeLockMode.NEVER,
/** True, sobald der Akku-Hinweis dem Nutzer einmal gezeigt wurde. */
val batteryHintShown: Boolean = false
)
private val Context.wakeLockDataStore by preferencesDataStore(name = "wake_lock_settings")
class WakeLockSettingsStore(private val context: Context) {
private companion object {
val KEY_MODE = stringPreferencesKey("wake_lock_mode")
val KEY_BATTERY_HINT_SHOWN = booleanPreferencesKey("battery_hint_shown")
}
val settings: Flow<WakeLockSettings> = context.wakeLockDataStore.data.map { prefs ->
WakeLockSettings(
mode = WakeLockMode.fromKey(prefs[KEY_MODE]),
batteryHintShown = prefs[KEY_BATTERY_HINT_SHOWN] == true
)
}
suspend fun currentSettings(): WakeLockSettings = settings.first()
suspend fun setMode(mode: WakeLockMode) {
context.wakeLockDataStore.edit { prefs ->
prefs[KEY_MODE] = mode.storageKey
}
}
suspend fun markBatteryHintShown() {
context.wakeLockDataStore.edit { prefs ->
prefs[KEY_BATTERY_HINT_SHOWN] = true
}
}
}
@@ -23,11 +23,15 @@ enum class PlaybackMode {
* @param latitude Breitengrad in Dezimalgrad
* @param longitude Längengrad in Dezimalgrad
* @param radiusMeters Erkennungsradius in Metern
* @param soundUri URI zur Audiodatei (content:// oder file://)
* @param soundName Anzeigename der Audiodatei
* @param soundUri Legacy-Single-Clip-URI (v2.5.11 und älter). In v2.5.12
* ist [audioClips] die führende Repräsentation; das
* Feld bleibt zur Rückwärtskompatibilität gefüllt
* (typischerweise = primary-Clip-URI), sodass alte
* Lese-Pfade unverändert funktionieren.
* @param soundName Legacy-Dateiname (v2.5.11 und älter). Wie [soundUri]
* aus Kompatibilitätsgründen weiter gepflegt.
* @param isActive Ob der Wegpunkt aktiv überwacht wird
* @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel:
* bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard".
* @param tourName Name der Tour/Route (Standard: "Standard").
*
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
*
@@ -39,6 +43,14 @@ enum class PlaybackMode {
* @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist
* @param allowedStartMinutes Tagesminute (01439), ab der das tägliche Zeitfenster beginnt
* @param allowedEndMinutes Tagesminute (01439), bis zu der das tägliche Zeitfenster endet
*
* --- v2.5.12 Multi-Clip ---
*
* @param audioClips Liste der Audio-Clips dieses Wegpunkts. Leer =
* Migration aus Legacy-Feldern [soundUri]/[soundName]
* via [effectiveClips]. Aktive Clips werden in
* Reihenfolge ([AudioClip.orderIndex]) abgespielt;
* inaktive werden übersprungen.
*/
data class Waypoint(
val id: String = UUID.randomUUID().toString(),
@@ -61,10 +73,118 @@ data class Waypoint(
val scheduleStartMillis: Long? = null,
val scheduleEndMillis: Long? = null,
val allowedStartMinutes: Int? = null,
val allowedEndMinutes: Int? = null
val allowedEndMinutes: Int? = null,
// v2.5.12 — Multi-Clip-Erweiterung. Default = leer, damit alte JSONs ohne
// diesen Key weiterhin laden (Gson füllt den Default ein).
val audioClips: List<AudioClip> = emptyList()
) {
companion object {
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
const val DEFAULT_TOUR_NAME = "Standard"
}
}
/**
* v2.5.12 — Liefert die *effektiv anzuwendende* Cliplist für diesen Wegpunkt.
*
* Verhalten:
* - Ist [Waypoint.audioClips] nicht leer, wird diese Liste in stabiler
* Sortierung (orderIndex, dann id) zurückgegeben.
* - Andernfalls wird sofern [Waypoint.soundUri] vorhanden ist ein
* synthetischer primary-Clip aus [Waypoint.soundUri]/[Waypoint.soundName]
* erzeugt. Damit funktionieren alle alten Wegpunkte ohne Migration
* transparent weiter.
* - Hat ein Wegpunkt weder [Waypoint.audioClips] noch [Waypoint.soundUri],
* ist die Rückgabe leer.
*
* Die ID des synthetischen Legacy-Clips wird deterministisch aus der Waypoint-
* ID abgeleitet, damit sich Skip-/UI-Zustände über mehrere Aufrufe hinweg
* stabil referenzieren lassen.
*/
fun Waypoint.effectiveClips(): List<AudioClip> {
if (audioClips.isNotEmpty()) {
return audioClips.sortedWith(
compareBy({ it.orderIndex }, { it.id })
)
}
if (soundUri.isBlank()) return emptyList()
return listOf(
AudioClip(
id = "legacy-$id",
title = soundName,
uri = soundUri,
displayFileName = soundName,
isActive = true,
isPrimary = true,
orderIndex = 0
)
)
}
/**
* Alle aktiven, abspielbaren Clips (für Tour-Play). Reihenfolge wie
* [effectiveClips]; ohne deaktivierte und ohne leere URIs.
*/
fun Waypoint.activeClips(): List<AudioClip> =
effectiveClips().filter { it.isActive && it.hasAudio }
/**
* Primärer Clip (Vorschau, Listenansicht, Legacy-Fallback).
*
* Priorität:
* 1. Erster Clip in [effectiveClips] mit isPrimary = true (sofern abspielbar).
* 2. Erster *aktiver* Clip mit URI.
* 3. Erster Clip mit URI überhaupt.
* 4. null, falls kein Audio existiert.
*/
fun Waypoint.primaryClip(): AudioClip? {
val clips = effectiveClips()
return clips.firstOrNull { it.isPrimary && it.hasAudio }
?: clips.firstOrNull { it.isActive && it.hasAudio }
?: clips.firstOrNull { it.hasAudio }
}
/**
* URI des primären Clips, oder Legacy-[Waypoint.soundUri] als Fallback.
* Praktisch für alle Stellen, die früher direkt `waypoint.soundUri` lasen.
*/
fun Waypoint.primarySoundUri(): String =
primaryClip()?.uri?.ifBlank { soundUri } ?: soundUri
/**
* Anzeigename des primären Clips, oder Legacy-[Waypoint.soundName].
*/
fun Waypoint.primarySoundName(): String =
primaryClip()?.effectiveTitle?.ifBlank { soundName } ?: soundName
/**
* v2.5.12 — Hilfsfunktion zum Setzen der Clipliste mit konsistenten
* Legacy-Spiegel-Feldern. Die Felder [Waypoint.soundUri] und
* [Waypoint.soundName] werden auf den primären Clip gesetzt, sodass
* - alte Lese-Pfade weiter den Hauptclip sehen,
* - JSON-Exports (legacy-Format) ein sinnvolles soundUri-Feld enthalten,
* - der Tour-Play (multi-clip-aware) trotzdem alle Clips kennt.
*
* orderIndex wird beim Speichern auf die Listen-Position normalisiert,
* damit die Reihenfolge stabil aus der UI übernommen werden kann.
*/
fun Waypoint.withClips(newClips: List<AudioClip>): Waypoint {
if (newClips.isEmpty()) {
return copy(audioClips = emptyList())
}
val normalized = newClips.mapIndexed { idx, c -> c.copy(orderIndex = idx) }
val primary = normalized.firstOrNull { it.isPrimary && it.hasAudio }
?: normalized.firstOrNull { it.isActive && it.hasAudio }
?: normalized.firstOrNull { it.hasAudio }
?: normalized.first()
// Genau einen Clip als primary markieren (idempotent)
val finalized = normalized.map { c ->
c.copy(isPrimary = (c.id == primary.id))
}
return copy(
audioClips = finalized,
soundUri = primary.uri,
soundName = primary.effectiveTitle
)
}
@@ -0,0 +1,57 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.4 — Sichtbarkeitsoptionen für die optionalen Tour-Werkzeuge der
* klassischen Tour-/Waypoint-Ansicht ([WaypointListScreen]). Atmo, PTT
* und Tour-Zähler lassen sich unabhängig voneinander ein- und ausblenden
* ("Ansicht anpassen"), damit die Tour-/Wegpunkt-Inhalte visuell im
* Vordergrund stehen.
*
* Default: alle drei Module sind sichtbar, damit bestehende Nutzer ihre
* gewohnten Bedienelemente nicht verlieren. Persistenz erfolgt über einen
* eigenen DataStore unabhängig von der bereits existierenden
* [HomeModulesSettings]-Logik der Startseite.
*/
data class WaypointListViewSettings(
val showPtt: Boolean = true,
val showAtmo: Boolean = true,
val showTourCounter: Boolean = true
)
private val Context.waypointListViewDataStore by preferencesDataStore(name = "waypoint_list_view_settings")
class WaypointListViewSettingsStore(private val context: Context) {
private companion object {
val KEY_SHOW_PTT = booleanPreferencesKey("wl_show_ptt")
val KEY_SHOW_ATMO = booleanPreferencesKey("wl_show_atmo")
val KEY_SHOW_TOUR_COUNTER = booleanPreferencesKey("wl_show_tour_counter")
}
val settings: Flow<WaypointListViewSettings> = context.waypointListViewDataStore.data.map { prefs ->
WaypointListViewSettings(
showPtt = prefs[KEY_SHOW_PTT] ?: true,
showAtmo = prefs[KEY_SHOW_ATMO] ?: true,
showTourCounter = prefs[KEY_SHOW_TOUR_COUNTER] ?: true
)
}
suspend fun setShowPtt(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_PTT] = value }
}
suspend fun setShowAtmo(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_ATMO] = value }
}
suspend fun setShowTourCounter(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_TOUR_COUNTER] = value }
}
}
@@ -38,8 +38,21 @@ class WaypointStore(private val context: Context) {
private val KEY_TOURS = stringPreferencesKey("tours_json")
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json")
private val KEY_TRACK_DRAFTS = stringPreferencesKey("track_drafts_json")
// v2.4.6 — persistente Tourrouten (tourName → TourRoute, JSON).
// Eigene Schlüssel-Datei: bestehende Backups/JSON ohne diesen Key
// bleiben gültig (Default = leere Map).
private val KEY_TOUR_ROUTES = stringPreferencesKey("tour_routes_json")
// v2.5.5 — Persistente Tour-Cover (tourName → TourCover, JSON).
// Optional pro Tour; fehlt der Eintrag, bleibt die deterministische
// Standard-Optik (Akzentfarbe + Icon-Heuristik aus Tournamen) erhalten.
private val KEY_TOUR_COVERS = stringPreferencesKey("tour_covers_json")
}
private val draftListType = object : TypeToken<List<TrackDraft>>() {}.type
private val tourRoutesMapType = object : TypeToken<Map<String, TourRoute>>() {}.type
private val tourCoversMapType = object : TypeToken<Map<String, TourCover>>() {}.type
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
@@ -72,6 +85,33 @@ class WaypointStore(private val context: Context) {
}.getOrDefault(emptyMap())
}
/**
* v2.4.6 — Persistente Tourrouten als Map (tourName → TourRoute).
* Rückwärtskompatibel: fehlt der Key komplett, ist die Map leer.
* Auch korrupte JSON werden defensiv auf leer abgefangen, damit eine
* einzelne kaputte Route nicht den Rest der App blockiert.
*/
val tourRoutesFlow: Flow<Map<String, TourRoute>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/**
* v2.5.5 — Persistente Tour-Cover als Map (tourName → TourCover).
* Rückwärtskompatibel: fehlt der Key, ist die Map leer und Touren
* bekommen ihre bisherige deterministische Hero-Optik.
*/
val tourCoversFlow: Flow<Map<String, TourCover>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
context.dataStore.data.map { prefs ->
@@ -82,6 +122,59 @@ class WaypointStore(private val context: Context) {
}.getOrDefault(emptyMap())
}
/**
* Reaktiver Flow der archivierten Track-Drafts.
* Drafts sind eigenständige, persistierte Aufzeichnungen — neue
* Aufzeichnungen werden als zusätzliche Einträge gespeichert,
* niemals durch Überschreiben.
*/
val trackDraftsFlow: Flow<List<TrackDraft>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType) ?: emptyList()
}.getOrDefault(emptyList())
}
/** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */
suspend fun addTrackDraft(draft: TrackDraft) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
list.add(draft)
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/**
* Aktualisiert einen vorhandenen Draft anhand seiner ID.
* Falls noch nicht vorhanden, wird er hinzugefügt (defensiv).
*/
suspend fun upsertTrackDraft(draft: TrackDraft) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
val idx = list.indexOfFirst { it.id == draft.id }
if (idx >= 0) list[idx] = draft else list.add(draft)
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/** Löscht einen Track-Draft anhand seiner ID. */
suspend fun deleteTrackDraft(id: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
list.removeAll { it.id == id }
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/** Speichert die gesamte Wegpunktliste. */
suspend fun saveAll(waypoints: List<Waypoint>) {
context.dataStore.edit { prefs ->
@@ -131,6 +224,125 @@ class WaypointStore(private val context: Context) {
}
}
/**
* v2.4.6 — Speichert (oder ersetzt) die gespeicherte Route einer Tour.
* Setzt [TourRoute.updatedAtMillis] auf die aktuelle Zeit, sofern das
* übergebene Objekt nicht selbst einen neueren Zeitstempel mitbringt.
*/
suspend fun saveTourRoute(tourName: String, route: TourRoute) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val stamped = route.copy(
updatedAtMillis = if (route.updatedAtMillis > 0) route.updatedAtMillis
else System.currentTimeMillis()
)
map[tourName] = stamped
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** Entfernt die gespeicherte Route einer Tour (falls vorhanden). */
suspend fun deleteTourRoute(tourName: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
if (map.remove(tourName) != null) {
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
}
/**
* Benennt die Tour einer gespeicherten Route um. Wird aus
* [WaypointViewModel.renameTour]/[deleteTour] aufgerufen, damit die
* Route nicht verwaist zurückbleibt.
*/
suspend fun renameTourRouteKey(oldTour: String, newTour: String) {
if (oldTour == newTour) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val existing = map.remove(oldTour) ?: return@edit
map[newTour] = existing.copy(updatedAtMillis = System.currentTimeMillis())
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** Bulk-Schreiben der Routen (für Backup-Import). Vorhandene Keys werden überschrieben. */
suspend fun mergeTourRoutes(routes: Map<String, TourRoute>) {
if (routes.isEmpty()) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.putAll(routes)
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** v2.5.5 — Speichert das Cover einer Tour (überschreibt vorhandenes). */
suspend fun saveTourCover(tourName: String, cover: TourCover) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map[tourName] = cover
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** v2.5.5 — Entfernt das Cover einer Tour (falls vorhanden). */
suspend fun deleteTourCover(tourName: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
if (map.remove(tourName) != null) {
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
}
/**
* v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen, damit er nicht
* unter einem verwaisten Tournamen liegen bleibt.
*/
suspend fun renameTourCoverKey(oldTour: String, newTour: String) {
if (oldTour == newTour) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val existing = map.remove(oldTour) ?: return@edit
map[newTour] = existing
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** v2.5.5 — Bulk-Schreiben der Cover (für Backup-Import). */
suspend fun mergeTourCovers(covers: Map<String, TourCover>) {
if (covers.isEmpty()) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.putAll(covers)
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** Löscht den GPS-Track einer Tour. */
suspend fun clearGpsTrack(tourName: String) {
context.dataStore.edit { prefs ->