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