package de.waypointaudio.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences 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.GsonBuilder import com.google.gson.reflect.TypeToken import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map // DataStore-Instanz als Extension Property (Singleton pro Context) private val Context.dataStore: DataStore by preferencesDataStore(name = "waypoints") /** * Lokaler persistenter Speicher für Wegpunkte und Touren. * Nutzt Jetpack DataStore mit JSON-Serialisierung via Gson. * * tourList speichert die geordnete Liste aller Tournamen (inkl. Standardtour). * Touren werden zusätzlich aus den Wegpunkt-tourName-Feldern abgeleitet, * damit auch leere Touren (ohne Wegpunkte) persistiert werden. * * tourPlaybackDefaults speichert die tour-weiten Abspiel-Vorgaben als JSON-Map. */ class WaypointStore(private val context: Context) { private val gson: Gson = GsonBuilder().create() private val waypointListType = object : TypeToken>() {}.type private val stringListType = object : TypeToken>() {}.type private val defaultsMapType = object : TypeToken>() {}.type companion object { private val KEY_WAYPOINTS = stringPreferencesKey("waypoints_json") private val KEY_TOURS = stringPreferencesKey("tours_json") private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json") private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json") private val KEY_TRACK_DRAFTS = stringPreferencesKey("track_drafts_json") // v2.4.6 — persistente Tourrouten (tourName → TourRoute, JSON). // Eigene Schlüssel-Datei: bestehende Backups/JSON ohne diesen Key // bleiben gültig (Default = leere Map). private val KEY_TOUR_ROUTES = stringPreferencesKey("tour_routes_json") // v2.5.5 — Persistente Tour-Cover (tourName → TourCover, JSON). // Optional pro Tour; fehlt der Eintrag, bleibt die deterministische // Standard-Optik (Akzentfarbe + Icon-Heuristik aus Tournamen) erhalten. private val KEY_TOUR_COVERS = stringPreferencesKey("tour_covers_json") } private val draftListType = object : TypeToken>() {}.type private val tourRoutesMapType = object : TypeToken>() {}.type private val tourCoversMapType = object : TypeToken>() {}.type /** Liefert einen Flow mit der aktuellen Wegpunktliste. */ val waypointsFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" runCatching { gson.fromJson>(json, waypointListType) ?: emptyList() }.getOrDefault(emptyList()) } /** * Liefert einen Flow mit der persistierten Tourliste. * Die Liste enthält mindestens die Standardtour. * Touren aus Wegpunkten werden ggf. ergänzt, aber die Reihenfolge * (und leere Touren) stammen aus diesem Key. */ val toursFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_TOURS] ?: "[]" val stored = runCatching { gson.fromJson>(json, stringListType) ?: emptyList() }.getOrDefault(emptyList()) // Immer mindestens die Standardtour enthalten if (stored.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else stored } /** Liefert einen Flow mit der Map tour → TourPlaybackDefaults. */ val tourDefaultsFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}" runCatching { gson.fromJson>(json, defaultsMapType) ?: emptyMap() }.getOrDefault(emptyMap()) } /** * v2.4.6 — Persistente Tourrouten als Map (tourName → TourRoute). * Rückwärtskompatibel: fehlt der Key komplett, ist die Map leer. * Auch korrupte JSON werden defensiv auf leer abgefangen, damit eine * einzelne kaputte Route nicht den Rest der App blockiert. */ val tourRoutesFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_TOUR_ROUTES] ?: "{}" runCatching { gson.fromJson>(json, tourRoutesMapType) ?: emptyMap() }.getOrDefault(emptyMap()) } /** * v2.5.5 — Persistente Tour-Cover als Map (tourName → TourCover). * Rückwärtskompatibel: fehlt der Key, ist die Map leer und Touren * bekommen ihre bisherige deterministische Hero-Optik. */ val tourCoversFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_TOUR_COVERS] ?: "{}" runCatching { gson.fromJson>(json, tourCoversMapType) ?: emptyMap() }.getOrDefault(emptyMap()) } /** GPS-Track-Flow: Map tour → Liste von TrackPoint. */ val gpsTracksFlow: Flow>> = context.dataStore.data.map { prefs -> val json = prefs[KEY_GPS_TRACKS] ?: "{}" runCatching { val mapType = object : TypeToken>>() {}.type gson.fromJson>>(json, mapType) ?: emptyMap() }.getOrDefault(emptyMap()) } /** * Reaktiver Flow der archivierten Track-Drafts. * Drafts sind eigenständige, persistierte Aufzeichnungen — neue * Aufzeichnungen werden als zusätzliche Einträge gespeichert, * niemals durch Überschreiben. */ val trackDraftsFlow: Flow> = context.dataStore.data.map { prefs -> val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" runCatching { gson.fromJson>(json, draftListType) ?: emptyList() }.getOrDefault(emptyList()) } /** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */ suspend fun addTrackDraft(draft: TrackDraft) { context.dataStore.edit { prefs -> val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, draftListType)?.toMutableList() }.getOrNull() ?: mutableListOf() list.add(draft) prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) } } /** * Aktualisiert einen vorhandenen Draft anhand seiner ID. * Falls noch nicht vorhanden, wird er hinzugefügt (defensiv). */ suspend fun upsertTrackDraft(draft: TrackDraft) { context.dataStore.edit { prefs -> val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, draftListType)?.toMutableList() }.getOrNull() ?: mutableListOf() val idx = list.indexOfFirst { it.id == draft.id } if (idx >= 0) list[idx] = draft else list.add(draft) prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) } } /** Löscht einen Track-Draft anhand seiner ID. */ suspend fun deleteTrackDraft(id: String) { context.dataStore.edit { prefs -> val json = prefs[KEY_TRACK_DRAFTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, draftListType)?.toMutableList() }.getOrNull() ?: mutableListOf() list.removeAll { it.id == id } prefs[KEY_TRACK_DRAFTS] = gson.toJson(list) } } /** * Speichert die gesamte Wegpunktliste. * * v2.7.21 — Waypoint-Preservation-Guard: * Wenn [waypoints] leer ist, aber im DataStore bereits Wegpunkte vorhanden * sind, wird der Schreibvorgang NICHT ausgeführt. Das verhindert, dass eine * versehentlich leere oder veraltete In-Memory-Liste (`_waypoints.value` * aus einem Race-Condition-Fenster) bestehende Wegpunkte überschreibt. * * Explizites Leeren (Tour-Löschung, Migration) muss [saveAllForced] verwenden. */ suspend fun saveAll(waypoints: List) { context.dataStore.edit { prefs -> // v2.7.21 — Preservation Guard: nie eine leere Liste über eine // nicht-leere Liste schreiben. Schutz gegen Race-Condition-Verlust. if (waypoints.isEmpty()) { val existingJson = prefs[KEY_WAYPOINTS] ?: "[]" val existingList = runCatching { gson.fromJson>(existingJson, waypointListType) }.getOrNull() if (!existingList.isNullOrEmpty()) { // Vorhandene Wegpunkte schützen — leere Liste wird NICHT geschrieben. android.util.Log.w( "WaypointStore", "saveAll() mit leerer Liste blockiert: ${existingList.size} bestehende Wegpunkte bleiben erhalten." ) return@edit } } prefs[KEY_WAYPOINTS] = gson.toJson(waypoints) } } /** * v2.7.21 — Forciertes Speichern ohne Preservation-Guard. * Nur für explizite Lösch- und Migrationsoperationen verwenden, * bei denen eine leere Liste das gewünschte Ergebnis ist. */ suspend fun saveAllForced(waypoints: List) { context.dataStore.edit { prefs -> prefs[KEY_WAYPOINTS] = gson.toJson(waypoints) } } /** Speichert die gesamte Tourliste (Reihenfolge bleibt erhalten). */ suspend fun saveTours(tours: List) { context.dataStore.edit { prefs -> prefs[KEY_TOURS] = gson.toJson(tours) } } /** Speichert oder aktualisiert die Abspiel-Vorgaben einer Tour. */ suspend fun saveTourDefaults(tourName: String, defaults: TourPlaybackDefaults) { context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, defaultsMapType) ?.toMutableMap() }.getOrNull() ?: mutableMapOf() map[tourName] = defaults prefs[KEY_TOUR_DEFAULTS] = gson.toJson(map) } } /** Liest die Abspiel-Vorgaben einer Tour einmalig (ohne Flow). */ suspend fun getTourDefaults(tourName: String): TourPlaybackDefaults { val json = context.dataStore.data.first()[KEY_TOUR_DEFAULTS] ?: "{}" val map: Map = runCatching { gson.fromJson>(json, defaultsMapType) ?: emptyMap() }.getOrDefault(emptyMap()) return map[tourName] ?: TourPlaybackDefaults(tourName = tourName) } /** Speichert den GPS-Track einer Tour. */ suspend fun saveGpsTrack(tourName: String, points: List) { context.dataStore.edit { prefs -> val json = prefs[KEY_GPS_TRACKS] ?: "{}" val mapType = object : TypeToken>>() {}.type val map: MutableMap> = runCatching { gson.fromJson>>(json, mapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() map[tourName] = points prefs[KEY_GPS_TRACKS] = gson.toJson(map) } } /** * 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. * * v2.7.21 — Waypoint-Preservation-Invariante: * Diese Funktion schreibt AUSSCHLIESSLICH in [KEY_TOUR_ROUTES]. * [KEY_WAYPOINTS] (Audio-Wegpunkte) wird durch diesen Aufruf NIEMALS * verändert. Tourrouten und Audio-Wegpunkte sind vollständig getrennte * Speicherbereiche. */ suspend fun saveTourRoute(tourName: String, route: TourRoute) { context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_ROUTES] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() val stamped = route.copy( updatedAtMillis = if (route.updatedAtMillis > 0) route.updatedAtMillis else System.currentTimeMillis() ) map[tourName] = stamped prefs[KEY_TOUR_ROUTES] = gson.toJson(map) } } /** Entfernt die gespeicherte Route einer Tour (falls vorhanden). */ suspend fun deleteTourRoute(tourName: String) { context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_ROUTES] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() if (map.remove(tourName) != null) { prefs[KEY_TOUR_ROUTES] = gson.toJson(map) } } } /** * Benennt die Tour einer gespeicherten Route um. Wird aus * [WaypointViewModel.renameTour]/[deleteTour] aufgerufen, damit die * Route nicht verwaist zurückbleibt. */ suspend fun renameTourRouteKey(oldTour: String, newTour: String) { if (oldTour == newTour) return context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_ROUTES] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() val existing = map.remove(oldTour) ?: return@edit map[newTour] = existing.copy(updatedAtMillis = System.currentTimeMillis()) prefs[KEY_TOUR_ROUTES] = gson.toJson(map) } } /** Bulk-Schreiben der Routen (für Backup-Import). Vorhandene Keys werden überschrieben. */ suspend fun mergeTourRoutes(routes: Map) { if (routes.isEmpty()) return context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_ROUTES] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourRoutesMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() map.putAll(routes) prefs[KEY_TOUR_ROUTES] = gson.toJson(map) } } /** v2.5.5 — Speichert das Cover einer Tour (überschreibt vorhandenes). */ suspend fun saveTourCover(tourName: String, cover: TourCover) { context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_COVERS] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourCoversMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() map[tourName] = cover prefs[KEY_TOUR_COVERS] = gson.toJson(map) } } /** v2.5.5 — Entfernt das Cover einer Tour (falls vorhanden). */ suspend fun deleteTourCover(tourName: String) { context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_COVERS] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourCoversMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() if (map.remove(tourName) != null) { prefs[KEY_TOUR_COVERS] = gson.toJson(map) } } } /** * v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen, damit er nicht * unter einem verwaisten Tournamen liegen bleibt. */ suspend fun renameTourCoverKey(oldTour: String, newTour: String) { if (oldTour == newTour) return context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_COVERS] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourCoversMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() val existing = map.remove(oldTour) ?: return@edit map[newTour] = existing prefs[KEY_TOUR_COVERS] = gson.toJson(map) } } /** v2.5.5 — Bulk-Schreiben der Cover (für Backup-Import). */ suspend fun mergeTourCovers(covers: Map) { if (covers.isEmpty()) return context.dataStore.edit { prefs -> val json = prefs[KEY_TOUR_COVERS] ?: "{}" val map: MutableMap = runCatching { gson.fromJson>(json, tourCoversMapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() map.putAll(covers) prefs[KEY_TOUR_COVERS] = gson.toJson(map) } } /** Löscht den GPS-Track einer Tour. */ suspend fun clearGpsTrack(tourName: String) { context.dataStore.edit { prefs -> val json = prefs[KEY_GPS_TRACKS] ?: "{}" val mapType = object : TypeToken>>() {}.type val map: MutableMap> = runCatching { gson.fromJson>>(json, mapType)?.toMutableMap() }.getOrNull() ?: mutableMapOf() map.remove(tourName) prefs[KEY_GPS_TRACKS] = gson.toJson(map) } } /** Fügt einen Wegpunkt hinzu oder aktualisiert einen vorhandenen (matching id). */ suspend fun upsert(waypoint: Waypoint) { context.dataStore.edit { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, waypointListType)?.toMutableList() }.getOrNull() ?: mutableListOf() val index = list.indexOfFirst { it.id == waypoint.id } if (index >= 0) list[index] = waypoint else list.add(waypoint) prefs[KEY_WAYPOINTS] = gson.toJson(list) } } /** Löscht einen Wegpunkt anhand seiner ID. */ suspend fun delete(id: String) { context.dataStore.edit { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, waypointListType)?.toMutableList() }.getOrNull() ?: mutableListOf() list.removeAll { it.id == id } prefs[KEY_WAYPOINTS] = gson.toJson(list) } } /** * Inkrementiert den playCount eines Wegpunkts atomar. * Wird vom WaypointLocationService nach erfolgreicher Wiedergabe aufgerufen. * @return den neuen Wegpunkt mit inkrementiertem playCount, oder null wenn nicht gefunden. */ suspend fun incrementPlayCount(waypointId: String): Waypoint? { var updated: Waypoint? = null context.dataStore.edit { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, waypointListType)?.toMutableList() }.getOrNull() ?: mutableListOf() val index = list.indexOfFirst { it.id == waypointId } if (index >= 0) { val wp = list[index].copy(playCount = list[index].playCount + 1) list[index] = wp updated = wp } prefs[KEY_WAYPOINTS] = gson.toJson(list) } return updated } /** * Setzt den playCount aller Wegpunkte einer Tour auf 0 zurück. * @return Anzahl der zurückgesetzten Wegpunkte. */ suspend fun resetPlayCountsForTour(tourName: String): Int { var count = 0 context.dataStore.edit { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, waypointListType)?.toMutableList() }.getOrNull() ?: mutableListOf() for (i in list.indices) { val wp = list[i] if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName && wp.playCount != 0) { list[i] = wp.copy(playCount = 0) count++ } } prefs[KEY_WAYPOINTS] = gson.toJson(list) } return count } /** * Wendet einen Abspiel-Modus auf alle Wegpunkte einer Tour an. * Optional: setzt auch maxPlayCount, optional: setzt playCount zurück. * @return Anzahl der aktualisierten Wegpunkte. */ suspend fun applyPlaybackModeForTour( tourName: String, mode: PlaybackMode, maxPlayCount: Int?, resetCounts: Boolean ): Int { var count = 0 context.dataStore.edit { prefs -> val json = prefs[KEY_WAYPOINTS] ?: "[]" val list: MutableList = runCatching { gson.fromJson>(json, waypointListType)?.toMutableList() }.getOrNull() ?: mutableListOf() for (i in list.indices) { val wp = list[i] if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName) { list[i] = wp.copy( playbackMode = mode, maxPlayCount = if (mode == PlaybackMode.LIMITED_COUNT) maxPlayCount else null, playCount = if (resetCounts) 0 else wp.playCount ) count++ } } prefs[KEY_WAYPOINTS] = gson.toJson(list) } return count } }