Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt
T
2026-06-22 11:07:32 +00:00

2691 lines
119 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.waypointaudio.viewmodel
import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.PlayableItem
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.PlaybackProgress
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourMusicStore
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.activeClips
import de.waypointaudio.data.effectiveClips
import de.waypointaudio.data.primarySoundUri
import de.waypointaudio.data.toPlayableSequence
import de.waypointaudio.data.withClips
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.firstOrNull
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import de.waypointaudio.io.BackupImportManager
import de.waypointaudio.io.ImportExportManager
import de.waypointaudio.ui.RoutePlan
import de.waypointaudio.repository.WaypointRepository
import de.waypointaudio.service.BackgroundMusicManager
import de.waypointaudio.service.LivePttManager
import de.waypointaudio.service.ManualAudioPlayer
import de.waypointaudio.service.WaypointAudioInterruptionCoordinator
import de.waypointaudio.service.TrackRecordingManager
import de.waypointaudio.service.WaypointLocationService
import de.waypointaudio.service.MusicPlaybackState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeout
/**
* ViewModel für die Haupt-UI.
* Hält die Wegpunktliste, Tourliste, den Dienst-Status und verwaltet
* Import/Export sowie die GPS-Position für neue Wegpunkte.
*
* Neu: Tour-weite Abspiel-Vorgaben (TourPlaybackDefaults) neue Wegpunkte
* erben automatisch playbackMode und maxPlayCount der aktuellen Tour.
* Neu: GPS-Track-Aufzeichnung (In-Memory + persistiert per Tour).
*/
class WaypointViewModel(application: Application) : AndroidViewModel(application) {
private val repository = WaypointRepository(application)
private val fusedLocationClient =
LocationServices.getFusedLocationProviderClient(application)
private val musicStore = TourMusicStore(application)
/** Begleitmusik-Manager (App-Singleton). Zugänglich für UI-Dialog. */
val musicManager = BackgroundMusicManager.getInstance(application)
/** Live/PTT Manager (App-Singleton). Zugänglich für UI. */
val pttManager = LivePttManager.getInstance(application)
/** Dedicated player for manual (UI-driven) audio playback. */
private val manualPlayer = ManualAudioPlayer().also { mp ->
// Beim PTT-Coordinator registrieren, sodass eine aktive manuelle
// Wegpunkt-Wiedergabe von PTT pausiert/fortgesetzt wird. Die
// "isCurrentlyPlaying"-Lambda stellt sicher, dass nur tatsächlich
// laufende Wiedergaben pausiert werden nicht bereits pausierte.
WaypointAudioInterruptionCoordinator.register(
source = WaypointAudioInterruptionCoordinator.Source.MANUAL,
isCurrentlyPlaying = { mp.isPlaying },
pause = { mp.pause() },
resume = { mp.resume() }
)
}
// ─── Begleitmusik State ───────────────────────────────────────────────────
/** True wenn Begleitmusik für die aktuelle Tour aktiviert und am Spielen ist. */
private val _musicPlaying = MutableStateFlow(false)
val musicPlaying: StateFlow<Boolean> = _musicPlaying.asStateFlow()
/** Aktueller Wiedergabe-Zustand des Begleitmusik-Players (für Mini-Player UI). */
val musicPlaybackState: StateFlow<MusicPlaybackState> = musicManager.player.playbackState
/** Aktuell geladene Begleitmusik-Einstellungen (für die UI). */
private val _musicSettings = MutableStateFlow(TourAudioSettings())
val musicSettings: StateFlow<TourAudioSettings> = _musicSettings.asStateFlow()
/**
* Fehlermeldung bei Begleitmusik (z. B. Stream-Fehler, keine Quelle konfiguriert).
* null = kein Fehler.
*/
private val _musicError = MutableStateFlow<String?>(null)
val musicError: StateFlow<String?> = _musicError.asStateFlow()
/** Aktuelle Wegpunktliste (alle Touren). */
private val _waypoints = MutableStateFlow<List<Waypoint>>(emptyList())
val waypoints: StateFlow<List<Waypoint>> = _waypoints.asStateFlow()
/**
* Geordnete Tourliste (persistent, inkl. leerer Touren).
* Enthält mindestens [Waypoint.DEFAULT_TOUR_NAME].
*/
private val _tourList = MutableStateFlow<List<String>>(listOf(Waypoint.DEFAULT_TOUR_NAME))
val tourList: StateFlow<List<String>> = _tourList.asStateFlow()
/**
* v2.7.5 — Öffentlich sichtbare Tourliste für die UI (Audio-Bibliothek, Tabs).
* Die Standard-Tour (DEFAULT_TOUR_NAME) wird ausgeblendet, sobald mindestens
* eine echte (benutzerdefinierte) Tour existiert. Intern bleibt DEFAULT_TOUR_NAME
* stets als Fallback erhalten — Daten werden nicht gelöscht.
* Wenn keine echten Touren vorhanden sind, wird DEFAULT_TOUR_NAME als
* einziger Eintrag angezeigt (leerer Zustand für neue Nutzer).
*/
val visibleTourList: StateFlow<List<String>> = _tourList
.map { list ->
val realTours = list.filter { it != Waypoint.DEFAULT_TOUR_NAME }
if (realTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else realTours
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = listOf(Waypoint.DEFAULT_TOUR_NAME)
)
/**
* Set of tour names that have been deleted optimistically.
* The combine collector must not re-add these from waypoint-derived tours.
*/
private val _deletedTours = mutableSetOf<String>()
/**
* v2.7.14 — Neutral-Explorer-Modus-Flag.
* Wenn true, unterdrückt der combine-Kollektor den automatischen Upgrade
* von DEFAULT_TOUR_NAME zur ersten echten Tour. Das ermöglicht einen
* bewusst leeren Explorer-Einstieg aus der Übersicht/Audio-Bibliothek.
* Wird durch selectNeutralTourForNewExplorer() aktiviert und durch
* selectTour(echte Tour) oder upsertFromNeutralExplorer() wieder deaktiviert.
*/
@Volatile
private var _neutralExplorerMode: Boolean = false
/**
* Aktuell ausgewählte Tour (für Tabs und Filter).
*/
private val _selectedTour = MutableStateFlow(Waypoint.DEFAULT_TOUR_NAME)
val selectedTour: StateFlow<String> = _selectedTour.asStateFlow()
/** Ob der Hintergrund-Dienst läuft. */
private val _serviceRunning = MutableStateFlow(false)
val serviceRunning: StateFlow<Boolean> = _serviceRunning.asStateFlow()
/**
* Einmalige Fehlermeldung für Import/Export-Fehler.
*/
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
/**
* Letzte bekannte GPS-Position (lat, lon) für den "Aktuellen Standort"-Button.
*/
private val _currentLocation = MutableStateFlow<Pair<Double, Double>?>(null)
val currentLocation: StateFlow<Pair<Double, Double>?> = _currentLocation.asStateFlow()
/** true während der GPS-Positionsabfrage läuft. */
private val _locationLoading = MutableStateFlow(false)
val locationLoading: StateFlow<Boolean> = _locationLoading.asStateFlow()
// ─── Manual Playback State ────────────────────────────────────────────────
/** Index (in the playable playlist) of the waypoint currently selected for manual play. */
private val _manualIndex = MutableStateFlow<Int>(-1)
val manualIndex: StateFlow<Int> = _manualIndex.asStateFlow()
/** Whether the manual player is actively playing right now. */
private val _manualPlaying = MutableStateFlow(false)
val manualPlaying: StateFlow<Boolean> = _manualPlaying.asStateFlow()
// v2.7.39 — Die in v2.7.37 eingeführte feste Stille-Pause zwischen
// manuellen Clips/Wegpunkten ist aus Stabilitätsgründen wieder entfernt.
// Auf einigen Samsung-Geräten führte der verzögerte Auto-Advance zu
// uneindeutigem Player-State und konnte fälschlich "Audio konnte nicht
// abgespielt werden" auslösen. Die Methode bleibt als No-op bestehen,
// damit bestehende Stop-/Skip-/Tourwechsel-Pfade ohne strukturelles Risiko
// weiter denselben Aufräumaufruf verwenden können.
private fun cancelManualGap() = Unit
/** Name of the waypoint currently loaded in the manual player (null = nothing loaded). */
private val _manualCurrentName = MutableStateFlow<String?>(null)
val manualCurrentName: StateFlow<String?> = _manualCurrentName.asStateFlow()
/**
* ID of the waypoint currently playing (or paused) in single-item mode.
* null means no single-item playback is active (either nothing playing or playlist mode).
* UI uses this to show the correct active/pause state on each card's play button.
*/
private val _singlePlayingWaypointId = MutableStateFlow<String?>(null)
val singlePlayingWaypointId: StateFlow<String?> = _singlePlayingWaypointId.asStateFlow()
/**
* True when the player is in single-item mode AND actively playing (not paused).
* Used by the card to decide whether to show a Pause or Play icon.
*/
private val _singlePlayingActive = MutableStateFlow(false)
val singlePlayingActive: StateFlow<Boolean> = _singlePlayingActive.asStateFlow()
// ─── v2.5.14: flache Playable-Sequence + Fortschritt ───────────────────────
/**
* v2.5.14 — Aktuell abgespielter Eintrag in der flachen Tour-Sequenz, oder
* null, wenn nichts geladen ist. UI verwendet [PlayableItem.clipIndex] /
* [PlayableItem.clipTotal] für die "Clip 2 von 3"-Anzeige im Detail-Sheet.
*/
private val _currentPlayable = MutableStateFlow<PlayableItem?>(null)
val currentPlayable: StateFlow<PlayableItem?> = _currentPlayable.asStateFlow()
/**
* v2.5.14 — Fortschritt (Position/Dauer) des manuellen Players.
* Wird durch [progressJob] alle [PROGRESS_TICK_MS] ms aktualisiert, solange
* Wiedergabe läuft. Im Pause/Stop-Zustand bleibt der letzte Snapshot
* stehen, sodass die UI nicht auf 0 zurückspringt.
*/
private val _playbackProgress = MutableStateFlow(PlaybackProgress.EMPTY)
val playbackProgress: StateFlow<PlaybackProgress> = _playbackProgress.asStateFlow()
/** Aktiver Polling-Job für den Fortschritt; läuft nur während echter Wiedergabe. */
private var progressJob: Job? = null
// ─── Tour-weite Abspiel-Vorgaben ──────────────────────────────────────────
/**
* Aktuell geladene Abspiel-Vorgaben für die ausgewählte Tour.
* Wird beim Tour-Wechsel und nach dem Speichern neuer Vorgaben aktualisiert.
*/
private val _tourDefaults = MutableStateFlow(TourPlaybackDefaults())
val tourDefaults: StateFlow<TourPlaybackDefaults> = _tourDefaults.asStateFlow()
// ─── GPS-Track-Aufzeichnung (delegiert an TrackRecordingManager) ──────────
/** Aktuell aufgezeichnete Track-Punkte kommt direkt vom TrackRecordingManager. */
val currentTrack: StateFlow<List<GpsTrackPoint>> = TrackRecordingManager.currentTrack
/** Ob gerade ein Track aufgezeichnet wird kommt direkt vom TrackRecordingManager. */
val isRecording: StateFlow<Boolean> = TrackRecordingManager.isRecording
/**
* Persistierter Track der aktuellen Tour (aus DataStore geladen).
*/
private val _persistedTrack = MutableStateFlow<List<GpsTrackPoint>>(emptyList())
val persistedTrack: StateFlow<List<GpsTrackPoint>> = _persistedTrack.asStateFlow()
// ─── Track-Drafts (Archiv eigenständiger Aufzeichnungen) ──────────────────
/** Reaktive Liste aller archivierten Track-Drafts. */
private val _trackDrafts = MutableStateFlow<List<TrackDraft>>(emptyList())
val trackDrafts: StateFlow<List<TrackDraft>> = _trackDrafts.asStateFlow()
/**
* ID des Drafts, der nach dem letzten Stop gerade automatisch erzeugt wurde.
* UI nutzt das, um direkt in den Editor zu springen.
*/
private val _lastSavedDraftId = MutableStateFlow<String?>(null)
val lastSavedDraftId: StateFlow<String?> = _lastSavedDraftId.asStateFlow()
// ─── Audio-Bibliothek (tour-übergreifend) ─────────────────────────────────
/**
* Aggregierte Audiodatei-Bibliothek, gespeist aus:
* - allen Wegpunkt-Audios (soundUri/soundName) aller Touren
* - allen Begleitmusik-Playlists aller Touren
*
* Dedupliziert nach uriString. Wird im Begleitmusik-Dialog angezeigt,
* damit bereits hinzugefügte Dateien erneut auswählbar sind, ohne sie
* neu über den Datei-Picker auswählen zu müssen.
*/
private val _audioLibrary = MutableStateFlow<List<PlaylistItem>>(emptyList())
val audioLibrary: StateFlow<List<PlaylistItem>> = _audioLibrary.asStateFlow()
// ─── Tourrouten (v2.4.6) ──────────────────────────────────────────────────
// Persistente Tour→Route-Map. Reaktiv aus dem Repository gespeist.
// Wegpunkte tragen keine Route — die Route gehört zur Tour.
private val _tourRoutes = MutableStateFlow<Map<String, de.waypointaudio.data.TourRoute>>(emptyMap())
val tourRoutes: StateFlow<Map<String, de.waypointaudio.data.TourRoute>> = _tourRoutes.asStateFlow()
/** Convenience: liefert die gespeicherte Route der aktuellen Tour (oder null). */
val activeTourRoute: StateFlow<de.waypointaudio.data.TourRoute?>
get() = _activeTourRoute
private val _activeTourRoute = MutableStateFlow<de.waypointaudio.data.TourRoute?>(null)
// ─── Session-Route (v2.6.2) ───────────────────────────────────────────────
// Hält die zuletzt im Explorer berechnete/geplante Route (OSRM oder Planlinie)
// für die aktuelle Tour. Überlebt Navigation/Workspace-Wechsel, da im ViewModel
// gespeichert. Wird beim Tourwechsel oder explizitem Löschen geleert.
// Kein Datenbankschreiben — dient ausschließlich der Session-Wiederherstellung.
// Enthält: tourName (zur Validierung) + TourRoute-Objekt.
private val _currentSessionRoute =
MutableStateFlow<Pair<String, de.waypointaudio.data.TourRoute>?>(null)
val currentSessionRoute: StateFlow<Pair<String, de.waypointaudio.data.TourRoute>?> =
_currentSessionRoute.asStateFlow()
/**
* v2.6.2 — Speichert die aktuell berechnete/geplante Route für die laufende
* Explorer-Session. Wird nicht in die Datenbank geschrieben, sondern dient
* der Wiederherstellung der Kartenvisualisierung nach einem Workspace-Wechsel.
* Zusätzlich wird die Route dauerhaft als Tourroute persistiert, damit sie
* auch nach App-Neustart sichtbar ist.
*/
fun setCurrentSessionRoute(
tourName: String,
route: de.waypointaudio.data.TourRoute
) {
_currentSessionRoute.value = tourName to route
// Gleichzeitig dauerhaft persistieren, damit activeTourRoute beim Zurückkehren
// die Route enthält und savedRouteLine sie als Backup zeichnet.
saveTourRouteFor(tourName, route)
}
/**
* v2.6.2 — Löscht die Session-Route (z. B. beim Tourwechsel oder explizitem
* Löschen durch den Nutzer). Berührt nicht die persistente Tourroute.
*/
fun clearCurrentSessionRoute() {
_currentSessionRoute.value = null
}
// ─── Session-Plan (v2.7.2) ───────────────────────────────────────────────
// Hält den zuletzt genutzten RoutePlan (Start/Via/Ziel) pro Tour.
// Überlebt Navigation/Workspace-Wechsel, da im ViewModel gespeichert.
// Wird beim echten Tourwechsel geleert. Kein DB-Schreiben.
private val _currentSessionPlan =
MutableStateFlow<Pair<String, RoutePlan>?>(null)
val currentSessionPlan: StateFlow<Pair<String, RoutePlan>?> =
_currentSessionPlan.asStateFlow()
/**
* v2.7.2 — Speichert den aktuellen RoutePlan für die laufende Session.
* Wird bei jedem Plan-Update aufgerufen (Start/Via/Ziel-Änderung).
*/
fun setCurrentSessionPlan(tourName: String, plan: RoutePlan) {
_currentSessionPlan.value = tourName to plan
}
/**
* v2.7.2 — Löscht den Session-Plan (beim Tourwechsel oder explizitem Leeren).
*/
fun clearCurrentSessionPlan() {
_currentSessionPlan.value = null
}
// ─── Tour-Cover (v2.5.5) ──────────────────────────────────────────────────
// Persistente Map tourName → TourCover. Wenn keine Auswahl existiert,
// behält die UI ihre deterministische Standard-Optik (siehe TourHeroSection).
private val _tourCovers = MutableStateFlow<Map<String, de.waypointaudio.data.TourCover>>(emptyMap())
val tourCovers: StateFlow<Map<String, de.waypointaudio.data.TourCover>> = _tourCovers.asStateFlow()
// ─────────────────────────────────────────────────────────────────────────
init {
viewModelScope.launch {
repository.waypoints.collect { list ->
_waypoints.value = list
}
}
viewModelScope.launch {
// Kombiniere persistent gespeicherte Tourliste mit Touren aus Wegpunkten
combine(repository.tours, repository.waypoints) { storedTours, wps ->
val wpTours = wps.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct()
val merged = (storedTours + wpTours).distinct()
if (merged.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else merged
}.collect { merged ->
// Merge the incoming authoritative list with any optimistic entries that
// were added to _tourList before persistence completed. This prevents the
// combine from briefly dropping a just-created tour and resetting the
// selected tour back to an older value.
// Exclude tours that were optimistically deleted so they are not re-added.
val optimistic = _tourList.value
val combined = (merged + optimistic)
.distinct()
.filter { it !in _deletedTours }
.let { if (it.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else it }
_tourList.value = combined
val current = _selectedTour.value
if (current.isNotBlank() && current !in combined) {
_selectedTour.value = combined.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
}
// v2.7.5 — Wenn Standard-Tour ausgewählt ist, aber echte Touren existieren,
// automatisch zur ersten echten Tour wechseln. Standard bleibt interner Fallback.
// v2.7.14 — Wenn _neutralExplorerMode aktiv ist, wird dieser Upgrade NICHT
// durchgeführt: Explorer soll leer/neutral bleiben (Einstieg aus Übersicht).
if (_selectedTour.value == Waypoint.DEFAULT_TOUR_NAME && !_neutralExplorerMode) {
val firstReal = combined.firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME }
if (firstReal != null) {
_selectedTour.value = firstReal
}
}
}
}
// Begleitmusik-Einstellungen für die initiale Tour laden
viewModelScope.launch {
loadMusicSettingsForCurrentTour()
}
// Tour-Vorgaben für Starttour laden
viewModelScope.launch {
loadTourDefaultsForCurrentTour()
}
// Persistierten Track für Starttour laden
viewModelScope.launch {
loadPersistedTrackForCurrentTour()
}
// Track-Drafts beobachten (Archiv)
viewModelScope.launch {
repository.trackDrafts.collect { list ->
// Neueste zuerst
_trackDrafts.value = list.sortedByDescending { it.updatedAt }
}
}
// v2.4.6 — Tourrouten beobachten und aktive Tourroute nachziehen.
viewModelScope.launch {
repository.tourRoutes.collect { map ->
_tourRoutes.value = map
_activeTourRoute.value = map[_selectedTour.value]
}
}
// Beim Tourwechsel die aktive Tourroute und Session-Route aktualisieren.
viewModelScope.launch {
_selectedTour.collect { tour ->
_activeTourRoute.value = _tourRoutes.value[tour]
// v2.6.2 — Session-Route leeren, wenn der Nutzer die Tour wechselt.
// So zeigt der Explorer nach einem Tourwechsel keine fremde Route.
_currentSessionRoute.value = null
// v2.7.2 — Session-Plan leeren beim Tourwechsel.
_currentSessionPlan.value = null
}
}
// v2.5.5 — Tour-Cover beobachten.
viewModelScope.launch {
repository.tourCovers.collect { map ->
_tourCovers.value = map
}
}
// Audio-Bibliothek aus Wegpunkten + Begleitmusik-Playlists aller Touren ableiten.
// So bleiben bereits hinzugefügte Audiodateien tour-übergreifend auswählbar.
viewModelScope.launch {
combine(repository.waypoints, musicStore.settingsFlow) { wps, musicMap ->
val items = mutableListOf<PlaylistItem>()
// 1) Begleitmusik-Playlists aller Touren
musicMap.values.forEach { settings ->
settings.localPlaylist.forEach { item ->
if (item.uriString.isNotBlank()) items += item
}
}
// 2) Wegpunkt-Audios (soundUri/soundName) Legacy.
wps.forEach { wp ->
if (wp.soundUri.isNotBlank()) {
items += PlaylistItem(
uriString = wp.soundUri,
displayName = wp.soundName.ifBlank {
wp.soundUri.substringAfterLast('/').substringAfterLast(':')
}
)
}
// v2.5.12 — alle Clip-URIs ebenfalls in die Bibliothek
// mitnehmen, damit sie tour-übergreifend wiederverwendbar
// sind (z. B. beim erneuten Anlegen eines Wegpunkts).
wp.audioClips.forEach { c ->
if (c.uri.isNotBlank()) {
items += PlaylistItem(
uriString = c.uri,
displayName = c.effectiveTitle.ifBlank {
c.uri.substringAfterLast('/').substringAfterLast(':')
}
)
}
}
}
items
.distinctBy { it.uriString }
.sortedBy { it.displayName.lowercase() }
}.collect { lib ->
_audioLibrary.value = lib
}
}
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
/** Wegpunkt hinzufügen oder aktualisieren. */
fun upsert(waypoint: Waypoint) {
viewModelScope.launch {
val safe = waypoint.copy(
tourName = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
repository.upsert(safe)
val currentTours = _tourList.value
if (safe.tourName !in currentTours) {
val updated = currentTours + safe.tourName
repository.saveTours(updated)
}
}
}
/** Wegpunkt löschen. */
fun delete(id: String) {
viewModelScope.launch { repository.delete(id) }
}
// ---------------------------------------------------------------------------
// Tour-Verwaltung
// ---------------------------------------------------------------------------
/**
* v2.5.34 — Explorer-Kontext-Fix:
* Setzt die ausgewählte Tour auf den neutralen Standard zurück,
* AUSSER wenn bereits echte (nicht-Standard) Touren existieren
* dann wird direkt die erste echte Tour gewählt.
*
* v2.7.10 — Waypoint-Persistence-Fix:
* Wenn eine spontane "Unbenannte Tour" im Explorer erzeugt wurde und der
* Nutzer den Explorer verlässt und erneut öffnet, darf selectDefaultTour()
* nicht blind auf DEFAULT zurückspringen und die frisch erstellten Wegpunkte
* hinter einem Tour-Filter verstecken. Stattdessen: Wenn echte Touren
* vorhanden sind, sofort zur ersten echten Tour wechseln (gleiche Logik wie
* im combine-Kollektor, aber synchron, um Race Conditions zu vermeiden).
*
* Der GPS-Dienst und die Wiedergabe werden dabei NICHT unterbrochen.
*/
fun selectDefaultTour() {
val knownTours = _tourList.value
val firstReal = knownTours.firstOrNull { it != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME }
if (firstReal != null) {
// Echte Touren existieren direkt zur ersten echten Tour wechseln.
// Kein Rücksprung auf DEFAULT_TOUR_NAME, damit Wegpunkte sichtbar bleiben.
selectTour(firstReal)
} else {
// Keine echten Touren Standard-Tour ist der einzig sinnvolle Zustand.
selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
}
}
/**
* v2.7.14 — Neutraler Explorer-Start aus der Übersicht/Audio-Bibliothek.
*
* Setzt selectedTour explizit auf DEFAULT_TOUR_NAME ("Unbenannte Tour") und
* aktiviert den Neutral-Explorer-Modus-Flag, der den automatischen Upgrade
* durch den combine-Kollektor (v2.7.5-Logik) verhindert.
*
* Konzept:
* - Explorer aus Audio-Bibliothek/Übersicht → diese Funktion → leerer Kontext
* - Explorer aus Touransicht/WaypointListScreen → selectTour(tourName) bleibt unverändert
*
* Der Flag wird zurückgesetzt, sobald:
* a) der Nutzer einen Wegpunkt im neutralen Kontext setzt (upsertFromNeutralExplorer)
* b) der Nutzer explizit eine echte Tour über selectTour() auswählt
*/
fun selectNeutralTourForNewExplorer() {
_neutralExplorerMode = true
// Direkt auf DEFAULT setzen; selectTour() würde den Guard auslösen (DEFAULT nicht in
// knownTours wenn nur echte Touren existieren), daher direktes Assignment.
_selectedTour.value = de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME
musicManager.loadTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
viewModelScope.launch {
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}
}
fun selectTour(tourName: String) {
// Guard: only select tours that are known, or fall back to first available.
val knownTours = _tourList.value
val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName
else knownTours.first()
// v2.7.14 — Wechsel zu einer echten Tour deaktiviert den Neutral-Explorer-Modus.
if (safeTarget != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
_neutralExplorerMode = false
}
// v2.7.39 — Legacy-Aufräumaufruf bleibt erhalten, ist nach Entfernen
// der festen manuellen Pause aber ein No-op. Aktiv laufende Wiedergabe
// bleibt vom bloßen Tour-Wechsel weiterhin unberührt (Persistenz, s. u.).
if (safeTarget != _selectedTour.value) {
cancelManualGap()
}
_selectedTour.value = safeTarget
// v2.5.30 — Playback-Persistenz beim Tour-Wechsel:
// Laufende Wiedergabe (manuell oder GPS-gesteuert) wird beim blossen
// Navigieren/Wechseln zwischen Touren NICHT gestoppt.
// Stop/Reset passiert nur bei expliziten Nutzeraktionen:
// - Play in einer anderen Tour → stoppt alte, startet neue
// - Stop-Button in der Player-Leiste
// - GPS-Dienst bewusst stoppen
//
// Frühere Zeilen, die hier manualPlayer.stop(), _manualPlaying, _manualIndex,
// _singlePlayingWaypointId, _currentPlayable und stopProgressTicker() gesetzt
// haben, sind entfernt. Sie verhinderten Playback-Persistenz beim Tour-Wechsel.
//
// Musik-Einstellungen der NEUEN Tour laden (keine Wiedergabe stoppen).
musicManager.loadTour(safeTarget)
viewModelScope.launch {
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}
// GPS-Track-Aufzeichnung bleibt beim Tour-Wechsel erhalten (v2.5.30).
// Früher wurde stopTrackRecording() + clearTrack() hier aufgerufen.
// Track-Aufzeichnungsstopp nur über expliziten Nutzer-Stop-Button.
}
fun addTour(tourName: String): Boolean {
val trimmed = tourName.trim()
if (trimmed.isBlank()) return false
val current = _tourList.value
if (trimmed in current) return false
// Optimistically update _tourList immediately so the UI sees a consistent state
// before the async DataStore write + combine-collector cycle completes.
val updated = current + trimmed
_tourList.value = updated
// Select the new tour right away (same thread, before any recomposition).
// Use direct assignment instead of selectTour() to avoid triggering
// music-loading coroutine before the tour list is fully stable.
_selectedTour.value = trimmed
// v2.7.14 — Neue echte Tour erstellt: Neutral-Explorer-Modus deaktivieren.
if (trimmed != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
_neutralExplorerMode = false
}
// Reset manual player state
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_manualCurrentName.value = null
_manualIndex.value = -1
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
viewModelScope.launch {
runCatching {
repository.saveTours(updated)
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourliste", e)
}
// Load music settings safely after persistence
runCatching {
musicManager.loadTour(trimmed)
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Musik für neue Tour '$trimmed'", e)
}
}
return true
}
/**
* v2.7.7 — Spontane-Tour-Autocreate (v2.7.8: "Unbenannte Tour"):
* Erzeugt eine neue echte Tour mit eindeutigem Namen ("Unbenannte Tour",
* "Unbenannte Tour 2", usw.) und wählt sie sofort als selectedTour.
* Wird aufgerufen, wenn im neutralen Explorer-Kontext (selectedTour ==
* DEFAULT_TOUR_NAME) der erste Wegpunkt gesetzt wird.
* Rückgabe: der Name der neu erzeugten Tour.
* Interner Funktionsname bleibt createSpontaneousTour() für minimales Risiko.
*/
fun createSpontaneousTour(): String {
val base = "Unbenannte Tour"
val existing = _tourList.value
var candidate = base
var counter = 2
while (candidate in existing) {
candidate = "$base $counter"
counter++
}
addTour(candidate) // setzt auch _selectedTour auf candidate
return candidate
}
/**
* v2.7.7 — Upsert-Wrapper für den neutralen Explorer-Kontext.
* Wenn [waypoint].tourName == DEFAULT_TOUR_NAME (neutral), wird automatisch
* eine neue Spontane Tour erzeugt, [waypoint] auf diese Tour umgeschrieben
* und erst dann gespeichert. Danach ist selectedTour = neue Tour.
* In allen anderen Kontexten verhält sich diese Funktion identisch zu [upsert].
*/
fun upsertFromNeutralExplorer(waypoint: Waypoint) {
val isNeutral = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == Waypoint.DEFAULT_TOUR_NAME
if (isNeutral) {
val newTourName = createSpontaneousTour()
val adjusted = waypoint.copy(tourName = newTourName)
upsert(adjusted)
} else {
upsert(waypoint)
}
}
/**
* Verschiebt eine Tour in der Tab-Reihenfolge.
* Index-Verschiebung: from → to. Ungültige Indizes werden ignoriert.
* Persistiert die neue Reihenfolge sofort.
*/
fun moveTour(fromIndex: Int, toIndex: Int) {
// v2.7.32 — Bugfix: Die Reorder-UI (TourReorderDialog) arbeitet auf
// visibleTourList (Standard-Tour ausgeblendet), moveTour operierte aber
// direkt auf _tourList (inkl. DEFAULT_TOUR_NAME). Lag die Standard-Tour
// an Index 0, ergab sich ein Off-by-one, wodurch die erste und letzte
// SICHTBARE Tour faktisch nicht mehr verschoben werden konnten.
// Fix: Indizes beziehen sich auf die sichtbare Liste; die ausgeblendete
// Standard-Tour behält ihre relative Position in _tourList.
val full = _tourList.value
val visible = full.filter { it != Waypoint.DEFAULT_TOUR_NAME }
if (fromIndex !in visible.indices || toIndex !in visible.indices) return
if (fromIndex == toIndex) return
val reorderedVisible = visible.toMutableList()
val item = reorderedVisible.removeAt(fromIndex)
reorderedVisible.add(toIndex, item)
// Merge zurück nach _tourList: ausgeblendete Standard-Tour(en) an ihrer
// ursprünglichen relativen Position erhalten, sichtbare Touren in neuer
// Reihenfolge einsetzen.
val iter = reorderedVisible.iterator()
val merged = full.map { name ->
if (name == Waypoint.DEFAULT_TOUR_NAME) name else iter.next()
}
_tourList.value = merged
viewModelScope.launch {
runCatching { repository.saveTours(merged) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tour-Reihenfolge", e)
}
}
}
/**
* Verschiebt einen Wegpunkt innerhalb der gefilterten Liste der aktuell ausgewählten Tour.
* Indizes beziehen sich auf die sichtbare Liste (nur Wegpunkte der aktuellen Tour).
* Persistiert die neue Reihenfolge sofort.
*/
fun moveWaypoint(fromIndex: Int, toIndex: Int) {
val tour = _selectedTour.value
val all = _waypoints.value
val tourIndices = all.indices.filter {
all[it].tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour
}
if (fromIndex !in tourIndices.indices || toIndex !in tourIndices.indices) return
if (fromIndex == toIndex) return
val mutable = all.toMutableList()
val srcGlobal = tourIndices[fromIndex]
val moved = mutable.removeAt(srcGlobal)
val dstGlobal = if (toIndex == tourIndices.size - 1) {
val rebuilt = mutable.indices.filter {
mutable[it].tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour
}
(rebuilt.lastOrNull() ?: -1) + 1
} else {
val targetGlobal = tourIndices[toIndex]
if (targetGlobal > srcGlobal) targetGlobal - 1 else targetGlobal
}
mutable.add(dstGlobal.coerceIn(0, mutable.size), moved)
_waypoints.value = mutable
viewModelScope.launch {
runCatching { repository.saveAll(mutable) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Wegpunkt-Reihenfolge", e)
}
}
}
fun renameTour(oldName: String, newName: String): Boolean {
val trimmed = newName.trim()
if (trimmed.isBlank()) return false
if (trimmed == oldName) return true
val current = _tourList.value
if (trimmed in current) return false
viewModelScope.launch {
val updated = current.map { if (it == oldName) trimmed else it }
repository.saveTours(updated)
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen.
val freshWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
val updatedWps = freshWps.map {
if (it.tourName == oldName) it.copy(tourName = trimmed) else it
}
repository.saveAllForced(updatedWps)
// v2.4.6 — Tourroute-Eintrag mitziehen, damit die Route nicht
// verwaist unter dem alten Namen liegen bleibt.
runCatching { repository.renameTourRouteKey(oldName, trimmed) }
// v2.5.5 — Tour-Cover analog mitziehen.
runCatching { repository.renameTourCoverKey(oldName, trimmed) }
if (_selectedTour.value == oldName) _selectedTour.value = trimmed
}
return true
}
// ---------------------------------------------------------------------------
// Tourrouten (v2.4.6)
// ---------------------------------------------------------------------------
/**
* Speichert/ersetzt die Route der aktuell ausgewählten Tour. Aktualisiert
* den Zeitstempel automatisch. Aufrufer (Routenplaner) übergibt das
* gewünschte [de.waypointaudio.data.TourRoute]-Objekt.
*/
fun saveTourRouteForCurrent(route: de.waypointaudio.data.TourRoute) {
val tour = _selectedTour.value
viewModelScope.launch {
runCatching {
repository.saveTourRoute(
tour,
route.copy(updatedAtMillis = System.currentTimeMillis())
)
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourroute", e)
_errorMessage.value = "Tourroute konnte nicht gespeichert werden."
}
}
}
/**
* Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive).
*
* v2.7.21 — Waypoint-Preservation-Invariante:
* Diese Funktion schreibt AUSSCHLIESSLICH in KEY_TOUR_ROUTES.
* Audio-Wegpunkte (KEY_WAYPOINTS) werden durch diesen Aufruf NIEMALS verändert.
* Der Routenplaner darf diese Funktion frei nutzen.
*/
fun saveTourRouteFor(tourName: String, route: de.waypointaudio.data.TourRoute) {
viewModelScope.launch {
runCatching {
repository.saveTourRoute(
tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME },
route.copy(updatedAtMillis = System.currentTimeMillis())
)
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourroute", e)
_errorMessage.value = "Tourroute konnte nicht gespeichert werden."
}
}
}
/** Löscht die gespeicherte Route der aktuell ausgewählten Tour. */
fun deleteTourRouteForCurrent() {
val tour = _selectedTour.value
viewModelScope.launch {
runCatching { repository.deleteTourRoute(tour) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Löschen der Tourroute", e)
}
}
}
// ---------------------------------------------------------------------------
// Tour-Cover (v2.5.5)
// ---------------------------------------------------------------------------
/** Setzt das Cover der aktuell ausgewählten Tour. */
fun setTourCoverForCurrent(cover: de.waypointaudio.data.TourCover) {
val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
// v2.5.7 — Wenn vorher ein eigenes Bild gewählt war und das neue Cover
// *kein* Bild enthält, das alte Bild aufräumen.
val previousImage = _tourCovers.value[tour]?.imageUri
if (!previousImage.isNullOrBlank() && previousImage != cover.imageUri) {
de.waypointaudio.util.TourCoverImageStore.delete(previousImage)
}
viewModelScope.launch {
runCatching { repository.saveTourCover(tour, cover) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern des Tour-Covers", e)
_errorMessage.value = "Tour-Cover konnte nicht gespeichert werden."
}
}
}
/** Entfernt das Cover der aktuell ausgewählten Tour (zurück zur Standard-Optik). */
fun clearTourCoverForCurrent() {
val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val previousImage = _tourCovers.value[tour]?.imageUri
if (!previousImage.isNullOrBlank()) {
de.waypointaudio.util.TourCoverImageStore.delete(previousImage)
}
viewModelScope.launch {
runCatching { repository.deleteTourCover(tour) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Löschen des Tour-Covers", e)
}
}
}
/**
* v2.5.7 — Übernimmt ein vom Nutzer gewähltes Bild als Cover der aktuellen
* Tour. Das Bild wird in die app-interne Ablage kopiert und im
* [de.waypointaudio.data.TourCover] persistiert. Icon/Palette bleiben als
* Fallback erhalten, falls das Bild später verloren geht.
*/
fun setTourCoverImageFromUri(uri: android.net.Uri) {
val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val app = getApplication<Application>()
viewModelScope.launch {
val savedPath = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
de.waypointaudio.util.TourCoverImageStore.saveFromUri(app, uri, tour)
}
if (savedPath == null) {
_errorMessage.value = "Bild konnte nicht übernommen werden."
return@launch
}
// Vorheriges Bild (falls vorhanden) aufräumen.
val previousImage = _tourCovers.value[tour]?.imageUri
if (!previousImage.isNullOrBlank() && previousImage != savedPath) {
de.waypointaudio.util.TourCoverImageStore.delete(previousImage)
}
val previous = _tourCovers.value[tour]
?: de.waypointaudio.data.TourCover()
val updated = previous.copy(imageUri = savedPath)
runCatching { repository.saveTourCover(tour, updated) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Bild-Cover speichern fehlgeschlagen", e)
_errorMessage.value = "Tour-Cover konnte nicht gespeichert werden."
}
}
}
/**
* v2.5.7 — Entfernt nur das Bild eines vorhandenen Covers (Icon/Palette
* bleiben erhalten). Wird vom Cover-Dialog ausgelöst, wenn der Nutzer
* auf die Icon/Farb-Variante zurückwechselt.
*/
fun clearTourCoverImageForCurrent() {
val tour = _selectedTour.value.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val previous = _tourCovers.value[tour] ?: return
if (previous.imageUri.isNullOrBlank()) return
de.waypointaudio.util.TourCoverImageStore.delete(previous.imageUri)
viewModelScope.launch {
runCatching { repository.saveTourCover(tour, previous.copy(imageUri = null)) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Bild-Cover entfernen fehlgeschlagen", e)
}
}
}
fun deleteTour(tourName: String): Boolean {
if (tourName == Waypoint.DEFAULT_TOUR_NAME) return false
// --- Optimistic update (immediate, before any async persistence) ---
// Mark as deleted so the combine collector does not re-add it.
_deletedTours.add(tourName)
val current = _tourList.value.toMutableList()
current.remove(tourName)
if (current.isEmpty()) current.add(Waypoint.DEFAULT_TOUR_NAME)
_tourList.value = current
// Switch selected tour immediately so ScrollableTabRow index stays valid.
val nextTour = current.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
if (_selectedTour.value == tourName) {
// Reset manual player state
// v2.7.39 — Legacy-Aufräumaufruf; nach Entfernen der festen
// manuellen Pause gibt es keinen verzögerten Auto-Advance-Job mehr.
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_manualCurrentName.value = null
_manualIndex.value = -1
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
_selectedTour.value = nextTour
}
// --- Async persistence ---
viewModelScope.launch {
runCatching {
repository.saveTours(current)
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen statt
// _waypoints.value, um sicherzustellen, dass keine Wegpunkte verloren
// gehen, falls _waypoints.value noch nicht vollständig geladen ist.
val freshWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
val updatedWps = freshWps.map {
if (it.tourName == tourName) it.copy(tourName = Waypoint.DEFAULT_TOUR_NAME) else it
}
// Forciert schreiben, da dies eine bewusste Mutations-Operation ist
// (Tour-Löschung mit Reassignment auf DEFAULT_TOUR_NAME).
repository.saveAllForced(updatedWps)
// Clear GPS track for the deleted tour
repository.clearGpsTrack(tourName)
// v2.4.6 — Gespeicherte Tourroute beim Löschen entfernen
runCatching { repository.deleteTourRoute(tourName) }
// v2.5.5 — Cover-Eintrag der Tour entfernen
runCatching { repository.deleteTourCover(tourName) }
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim L\u00f6schen der Tour '$tourName'", e)
}
// Load settings for the now-selected tour
runCatching {
musicManager.loadTour(nextTour, force = true)
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Laden nach Tour-L\u00f6schen", e)
}
}
return true
}
// ---------------------------------------------------------------------------
// v2.5.15 — Wegpunkt-/Tour-Varianten
// ---------------------------------------------------------------------------
/**
* Kopiert einen Wegpunkt (inkl. aller Audio-Clips) in die angegebene
* Zieltour. Vergibt frische IDs für Waypoint und Clips, sodass spätere
* Bearbeitungen unabhängig vom Original bleiben. Audio-URIs werden
* bewusst nicht physisch dupliziert — sie zeigen weiter auf dieselben
* persistenten Medienreferenzen (content://-URIs). Das hält den
* Speicherbedarf minimal und ist Backup-kompatibel, weil der Backup-
* Export Audio bereits anhand der URI sichert.
*
* Der kopierte Wegpunkt wird ans Ende der Zieltour gehängt (über die
* native Reihenfolge in der globalen Wegpunktliste). PlayCount wird
* zurückgesetzt, damit die Variante ihr eigenes Zähl-/Schedule-
* Verhalten bekommt.
*
* @return true, wenn die Kopie angelegt werden konnte.
*/
fun copyWaypointToTour(
sourceWaypointId: String,
targetTourName: String,
newName: String? = null
): Boolean {
val source = _waypoints.value.firstOrNull { it.id == sourceWaypointId }
?: return false
val tour = targetTourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME }
// Neue Audio-Clip-IDs vergeben, damit Bearbeitungen der Kopie nicht
// den Original-Clip mutieren. URI/Dateiname/Reihenfolge bleiben.
val sourceClips = source.effectiveClips()
val copiedClips = sourceClips.map { c ->
c.copy(id = java.util.UUID.randomUUID().toString())
}
val baseName = (newName ?: source.name).ifBlank { source.name.ifBlank { "Wegpunkt" } }
val copy = source.copy(
id = java.util.UUID.randomUUID().toString(),
name = baseName,
tourName = tour,
// PlayCount neu starten; geplante Zeitfenster bleiben unverändert
// erhalten, damit die Variante 1:1 weiterläuft, wo der User sie
// erwartet.
playCount = 0,
audioClips = emptyList(), // wird via withClips() gesetzt
).let { base ->
if (copiedClips.isEmpty()) base.copy(soundUri = "", soundName = "")
else base.withClips(copiedClips)
}
viewModelScope.launch {
repository.upsert(copy)
// Sicherstellen, dass die Zieltour in der Tourliste existiert.
val currentTours = _tourList.value
if (tour !in currentTours) {
runCatching { repository.saveTours(currentTours + tour) }
}
_infoMessage.value =
"Wegpunkt „${copy.name}“ in Tour „$tour“ kopiert."
}
return true
}
/**
* Dupliziert eine komplette Tour als neue Variante. Vergibt frische
* IDs für die neue Tour (über den neuen Tournamen), alle Waypoints
* und alle Clips. Tour-Cover, Tour-Route, Tour-Defaults und
* Musik-Settings werden mit dem neuen Tournamen mitkopiert, sodass
* die Variante optisch und akustisch identisch startet, danach aber
* unabhängig weiterentwickelt werden kann. Medien-URIs werden — wie
* beim Wegpunkt-Kopieren — referenziert, nicht physisch dupliziert.
*
* @param sourceTourName Quelltour. Wenn nicht vorhanden, schlägt der
* Aufruf still fehl (false).
* @param newTourName Gewünschter Name der Variante. Wird getrimmt;
* leere oder bereits existierende Namen werden
* abgelehnt.
* @param selectAfter Wenn true, wird die neue Tour direkt aktiv
* (Tab-Auswahl), sodass der User die Variante
* sofort sieht.
* @return true bei Erfolg, false bei Validierungsfehler.
*/
fun duplicateTour(
sourceTourName: String,
newTourName: String,
selectAfter: Boolean = true
): Boolean {
val trimmed = newTourName.trim()
if (trimmed.isBlank()) return false
val currentTours = _tourList.value
if (trimmed in currentTours) return false
val safeSource = sourceTourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val sourceWps = _waypoints.value.filter {
it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == safeSource
}
// Tourliste sofort optimistisch erweitern, damit Tabs/Selection
// konsistent bleiben.
val updatedTours = currentTours + trimmed
_tourList.value = updatedTours
if (selectAfter) _selectedTour.value = trimmed
viewModelScope.launch {
runCatching {
repository.saveTours(updatedTours)
// Wegpunkte duplizieren jede ID neu, Clips ebenfalls neu.
val newWps = sourceWps.map { wp ->
val newClips = wp.effectiveClips().map { c ->
c.copy(id = java.util.UUID.randomUUID().toString())
}
val base = wp.copy(
id = java.util.UUID.randomUUID().toString(),
tourName = trimmed,
playCount = 0,
audioClips = emptyList(),
)
if (newClips.isEmpty()) base.copy(soundUri = "", soundName = "")
else base.withClips(newClips)
}
if (newWps.isNotEmpty()) {
// v2.7.21 — Race-Condition-Fix: Statt _waypoints.value (könnte noch
// leer sein, wenn der DataStore-Lade-Coroutine noch nicht feuerte)
// direkt aus dem DataStore lesen. Das garantiert die vollständige
// Wegpunktliste und verhindert versehentlichen Datenverlust.
val currentWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
repository.saveAll(currentWps + newWps)
}
// Tour-Cover, Tour-Route und Tour-Defaults referenziell mitkopieren.
runCatching {
val covers = repository.tourCovers.firstOrNull()
covers?.get(safeSource)?.let { cover ->
repository.saveTourCover(trimmed, cover)
}
}
runCatching {
val routes = repository.tourRoutes.firstOrNull()
routes?.get(safeSource)?.let { route ->
repository.saveTourRoute(
trimmed,
route.copy(updatedAtMillis = System.currentTimeMillis())
)
}
}
runCatching {
val defaults = repository.getTourDefaults(safeSource)
repository.saveTourDefaults(trimmed, defaults)
}
}.onFailure { e ->
android.util.Log.e(
"WaypointViewModel",
"Fehler beim Duplizieren der Tour '$safeSource' → '$trimmed'", e
)
_errorMessage.value = "Tour konnte nicht dupliziert werden."
}
if (selectAfter) {
runCatching {
musicManager.loadTour(trimmed, force = true)
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}
}
_infoMessage.value = "Tour „$trimmed“ als Variante angelegt."
}
return true
}
// ---------------------------------------------------------------------------
// Dienst-Steuerung
// ---------------------------------------------------------------------------
fun startService() {
val ctx = getApplication<Application>()
val intent = Intent(ctx, WaypointLocationService::class.java).apply {
action = WaypointLocationService.ACTION_START
}
ctx.startForegroundService(intent)
_serviceRunning.value = true
}
fun stopService() {
val ctx = getApplication<Application>()
val intent = Intent(ctx, WaypointLocationService::class.java).apply {
action = WaypointLocationService.ACTION_STOP
}
ctx.startService(intent)
_serviceRunning.value = false
}
fun setServiceRunning(running: Boolean) {
_serviceRunning.value = running
}
// ---------------------------------------------------------------------------
// Import / Export
// ---------------------------------------------------------------------------
fun exportJson(uri: Uri) {
viewModelScope.launch {
val error = ImportExportManager.exportJson(getApplication(), uri, _waypoints.value)
if (error != null) _errorMessage.value = error
}
}
/**
* v2.5.18 — JSON-Import ist immer additiv.
*
* Bestehende Wegpunkte und Touren werden NIE gelöscht oder ersetzt. Die
* importierten Wegpunkte werden zur bestehenden Liste hinzugefügt. IDs
* werden falls bereits vorhanden neu vergeben, sodass keine bestehenden
* Einträge überschrieben werden.
*
* Hinweis: Der frühere zweite Parameter `merge` (Default = false, der
* destruktiv `repository.saveAll(imported)` aufrief) ist entfernt. Die
* sichtbare JSON-Import-UI ist in v2.5.18 ausgeblendet; das Backup-System
* übernimmt diese Rolle. Diese Funktion bleibt als sichere additive
* Fallback-Variante erhalten.
*/
fun importJson(uri: Uri) {
viewModelScope.launch {
val (imported, error) = ImportExportManager.importJson(getApplication(), uri)
if (error != null) {
_errorMessage.value = error
return@launch
}
appendWaypointsAdditively(imported)
}
}
fun exportGpx(uri: Uri) {
viewModelScope.launch {
val error = ImportExportManager.exportGpx(getApplication(), uri, _waypoints.value)
if (error != null) _errorMessage.value = error
}
}
// ---------------------------------------------------------------------------
// v2.5.18 — Additiver GPX-Import
//
// Der GPX-Import erstellt entweder eine NEUE Tour oder fügt zu einer
// bestehenden Tour hinzu. In keinem Fall werden bestehende Touren oder
// Wegpunkte gelöscht oder ersetzt.
// ---------------------------------------------------------------------------
/**
* Tour-Vorschlag aus einem GPX-URI ableiten (Dateiname ohne Endung).
* Fallback: "GPX-Import <Datum>".
*/
fun suggestTourNameForGpx(uri: Uri): String {
val raw = runCatching {
val ctx = getApplication<Application>()
ctx.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst()) c.getString(idx) else null
}
}.getOrNull()
val base = raw?.substringBeforeLast('.')?.trim().orEmpty()
if (base.isNotBlank()) return base
val ts = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date())
return "GPX-Import $ts"
}
/**
* Liest eine GPX-Datei und legt eine neue Tour mit den importierten
* Wegpunkten an. Bestehende Touren und Wegpunkte bleiben unverändert.
*
* Hat eine Tour mit dem gewünschten Namen bereits Einträge, wird der
* Name um eine laufende Nummer ergänzt (z. B. "Foo (2)"), damit nichts
* überschrieben wird.
*/
fun importGpxAsNewTour(uri: Uri, requestedTourName: String) {
viewModelScope.launch {
val (imported, error) = ImportExportManager.importGpx(getApplication(), uri)
if (error != null) {
_errorMessage.value = error
return@launch
}
if (imported.isEmpty()) {
_errorMessage.value = "GPX enthält keine Wegpunkte"
return@launch
}
val finalTourName = uniqueTourName(
requestedTourName.trim().ifBlank { suggestTourNameForGpx(uri) }
)
val existingIds = _waypoints.value.map { it.id }.toHashSet()
val prepared = imported.map { wp ->
wp.copy(
id = if (wp.id.isBlank() || existingIds.contains(wp.id))
UUID.randomUUID().toString() else wp.id,
tourName = finalTourName
)
}
val combined = _waypoints.value + prepared
repository.saveAll(combined)
val currentTours = _tourList.value
if (!currentTours.contains(finalTourName)) {
repository.saveTours(currentTours + finalTourName)
}
_infoMessage.value = "GPX importiert: ${prepared.size} Wegpunkte in Tour \"$finalTourName\""
}
}
/**
* Liest eine GPX-Datei und hängt die importierten Wegpunkte an eine
* bereits bestehende Tour an. Bestehende Wegpunkte dieser Tour bleiben
* vollständig erhalten; die neuen Wegpunkte werden ans Ende der Liste
* angefügt. Andere Touren werden nicht angefasst.
*/
fun importGpxIntoExistingTour(uri: Uri, targetTourName: String) {
viewModelScope.launch {
if (targetTourName.isBlank()) {
_errorMessage.value = "Zieltour ist leer"
return@launch
}
val (imported, error) = ImportExportManager.importGpx(getApplication(), uri)
if (error != null) {
_errorMessage.value = error
return@launch
}
if (imported.isEmpty()) {
_errorMessage.value = "GPX enthält keine Wegpunkte"
return@launch
}
val existingIds = _waypoints.value.map { it.id }.toHashSet()
val prepared = imported.map { wp ->
wp.copy(
id = if (wp.id.isBlank() || existingIds.contains(wp.id))
UUID.randomUUID().toString() else wp.id,
tourName = targetTourName
)
}
val combined = _waypoints.value + prepared
repository.saveAll(combined)
val currentTours = _tourList.value
if (!currentTours.contains(targetTourName)) {
repository.saveTours(currentTours + targetTourName)
}
_infoMessage.value = "GPX importiert: ${prepared.size} Wegpunkte an \"$targetTourName\" angehängt"
}
}
/**
* Interner additiver Append-Pfad für JSON / Legacy. Wahrt alle IDs der
* bestehenden Wegpunkte; importierte Duplikat-IDs werden neu vergeben.
*/
private suspend fun appendWaypointsAdditively(imported: List<Waypoint>) {
if (imported.isEmpty()) return
val existingIds = _waypoints.value.map { it.id }.toHashSet()
val prepared = imported.map { wp ->
wp.copy(
id = if (wp.id.isBlank() || existingIds.contains(wp.id))
UUID.randomUUID().toString() else wp.id,
tourName = wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
}
val combined = _waypoints.value + prepared
repository.saveAll(combined)
val importedTours = prepared.map { it.tourName }.distinct()
val currentTours = _tourList.value
val merged = (currentTours + importedTours).distinct()
if (merged != currentTours) repository.saveTours(merged)
}
/** Wählt einen Tournamen, der noch nicht in [tourList] vorkommt. */
private fun uniqueTourName(base: String): String {
val existing = _tourList.value.toHashSet()
if (!existing.contains(base)) return base
var i = 2
while (existing.contains("$base ($i)")) i++
return "$base ($i)"
}
fun clearError() {
_errorMessage.value = null
}
// ---------------------------------------------------------------------------
// Backup-Export (ZIP)
// ---------------------------------------------------------------------------
/** Letzte Erfolgsmeldung des Backup-Exports/-Imports (German), null wenn keine. */
private val _infoMessage = MutableStateFlow<String?>(null)
val infoMessage: StateFlow<String?> = _infoMessage.asStateFlow()
fun clearInfo() { _infoMessage.value = null }
/** Setzt eine kurze Erfolgs-/Info-Meldung (Snackbar/Top-Banner). */
fun postInfo(message: String) { _infoMessage.value = message }
/** Setzt eine kurze Fehlermeldung (Snackbar/Top-Banner). */
fun postError(message: String) { _errorMessage.value = message }
/**
* Manifest-Vorschau eines ausgewählten Backup-ZIPs (ein einmaliger Snapshot).
* Wird nach Auswahl der ZIP-Datei gesetzt und vom Import-Dialog gelesen.
*/
private val _backupPreview = MutableStateFlow<BackupPreview?>(null)
val backupPreview: StateFlow<BackupPreview?> = _backupPreview.asStateFlow()
/** Hält die zuletzt vom Nutzer bestätigte Quell-URI für den Import. */
private var pendingBackupUri: Uri? = null
fun clearBackupPreview() {
_backupPreview.value = null
pendingBackupUri = null
}
/**
* Beschreibt den Inhalt einer Backup-ZIP-Datei (zur Anzeige im Dialog).
*/
data class BackupPreview(
val createdAt: String?,
val appVersionName: String?,
val sectionToursAndWaypoints: Boolean,
val sectionTrackDrafts: Boolean,
val sectionMediaAndAtmo: Boolean,
val sectionSettings: Boolean
)
/**
* Schreibt ein ZIP-Backup an die Ziel-URI mit den ausgewählten Sektionen.
* Liest Tourliste, Wegpunkte, Tour-Vorgaben, GPS-Tracks und Musik-Einstellungen
* aus dem Repository (jeweils ein einmaliger Snapshot).
*/
fun exportBackupZip(
uri: Uri,
toursAndWaypoints: Boolean,
trackDrafts: Boolean,
mediaAndAtmo: Boolean,
settings: Boolean
) {
viewModelScope.launch {
val ctx = getApplication<Application>()
// v2.7.36 FIX: Das gesamte Backup (Flow-Reads, ZIP-Aufbau und das
// chunk-weise Kopieren aller Mediendateien) lief bisher direkt im
// viewModelScope auf dem Main-Dispatcher. Bei Backups mit Medien
// blockierte das den UI-Thread für Sekunden → ANR ("App reagiert
// nicht / Absturz"), obwohl die Datei erfolgreich geschrieben wurde.
// Wie der Import-Pfad läuft die schwere IO jetzt auf Dispatchers.IO;
// die State-Updates für Erfolg/Fehler bleiben danach auf Main.
val result = runCatching {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap())
val tourDefaultsMap = runCatching {
repository.tourDefaults.first()
}.getOrDefault(emptyMap())
val musicSettingsMap = runCatching {
musicStore.settingsFlow.first()
}.getOrDefault(emptyMap())
val pkgInfo = runCatching {
ctx.packageManager.getPackageInfo(ctx.packageName, 0)
}.getOrNull()
@Suppress("DEPRECATION")
val versionCode = pkgInfo?.let {
runCatching { it.longVersionCode.toInt() }
.getOrDefault(it.versionCode)
} ?: 0
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
val input = de.waypointaudio.io.BackupExportManager.BackupInput(
waypoints = _waypoints.value,
tours = _tourList.value,
tourDefaults = tourDefaultsMap,
gpsTracks = gpsTracks,
musicSettings = musicSettingsMap,
tourRoutes = tourRoutesMap,
tourCovers = tourCoversMap,
trackDrafts = trackDraftsSnapshot,
appVersionName = pkgInfo?.versionName ?: "",
appVersionCode = versionCode
)
val selection = de.waypointaudio.io.BackupExportManager.BackupSelection(
toursAndWaypoints = toursAndWaypoints,
trackDrafts = trackDrafts,
mediaAndAtmo = mediaAndAtmo,
settings = settings
)
de.waypointaudio.io.BackupExportManager.writeBackup(
ctx, uri, selection, input
)
}
}.getOrElse { e ->
// Falls trotz interner Absicherung etwas Unerwartetes durchschlägt
// (z. B. SecurityException auf der Ziel-URI): nicht abstürzen,
// sondern als nicht-fataler Fehler melden.
de.waypointaudio.io.BackupExportManager.BackupResult(
errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
)
}
if (result.errorMessage != null) {
_errorMessage.value = result.errorMessage
} else {
val sectionList = if (result.sectionsWritten.isEmpty()) "(keine)"
else result.sectionsWritten.joinToString("; ")
val mediaPart = if (mediaAndAtmo) {
" · Medien: ${result.mediaCopied} kopiert, ${result.mediaFailed} nicht erreichbar"
} else ""
_infoMessage.value = "Backup erfolgreich erstellt. Inhalt: $sectionList$mediaPart"
}
}
}
// ---------------------------------------------------------------------------
// Backup-Import (ZIP)
// ---------------------------------------------------------------------------
/**
* Liest das Manifest des ausgewählten Backup-ZIPs (off-Main),
* setzt anschließend [_backupPreview] auf die enthaltenen Sektionen.
* Liest KEINE Daten in den DataStore das passiert erst nach Bestätigung.
*/
fun loadBackupPreview(uri: Uri) {
// Während GPS-Aufzeichnung kein Import zulassen.
if (TrackRecordingManager.isRecording.value) {
_errorMessage.value =
"GPS-Track-Aufzeichnung läuft. Bitte zuerst stoppen, bevor ein Backup importiert wird."
return
}
viewModelScope.launch {
val ctx = getApplication<Application>()
val info = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
BackupImportManager.readManifest(ctx, uri)
}
if (info.errorMessage != null) {
_errorMessage.value = info.errorMessage
pendingBackupUri = null
_backupPreview.value = null
return@launch
}
pendingBackupUri = uri
_backupPreview.value = BackupPreview(
createdAt = info.createdAt,
appVersionName = info.appVersionName,
sectionToursAndWaypoints = info.sectionsAvailable.toursAndWaypoints,
sectionTrackDrafts = info.sectionsAvailable.trackDrafts,
sectionMediaAndAtmo = info.sectionsAvailable.mediaAndAtmo,
sectionSettings = info.sectionsAvailable.settings
)
}
}
/**
* Importiert das zuvor mit [loadBackupPreview] geladene Backup mit den
* vom Nutzer ausgewählten Sektionen. Merge-Strategie:
* Bestehende Daten werden NICHT überschrieben; neue Touren mit Namenskollision
* werden umbenannt.
*/
fun confirmBackupImport(
toursAndWaypoints: Boolean,
trackDrafts: Boolean,
mediaAndAtmo: Boolean,
settings: Boolean
) {
val uri = pendingBackupUri ?: run {
_errorMessage.value = "Keine Backup-Datei ausgewählt."
return
}
if (TrackRecordingManager.isRecording.value) {
_errorMessage.value =
"GPS-Track-Aufzeichnung läuft. Bitte zuerst stoppen, bevor ein Backup importiert wird."
return
}
viewModelScope.launch {
val ctx = getApplication<Application>()
val existingTracks = runCatching { repository.gpsTracks.first() }
.getOrDefault(emptyMap())
val existingDefaults = runCatching { repository.tourDefaults.first() }
.getOrDefault(emptyMap())
val existingMusic = runCatching { musicStore.settingsFlow.first() }
.getOrDefault(emptyMap())
val existingTours = _tourList.value
val existingWps = _waypoints.value
val existingTourRoutes = runCatching { repository.tourRoutes.first() }
.getOrDefault(emptyMap())
val existingTourCovers = runCatching { repository.tourCovers.first() }
.getOrDefault(emptyMap())
val (payload, error) = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
BackupImportManager.buildImportPayload(
context = ctx,
sourceUri = uri,
selection = BackupImportManager.ImportSelection(
toursAndWaypoints = toursAndWaypoints,
trackDrafts = trackDrafts,
mediaAndAtmo = mediaAndAtmo,
settings = settings
),
existingWaypoints = existingWps,
existingTours = existingTours,
existingTourDefaults = existingDefaults,
existingTracks = existingTracks,
existingMusic = existingMusic,
existingTourRoutes = existingTourRoutes,
existingTourCovers = existingTourCovers
)
}
if (error != null || payload == null) {
_errorMessage.value = error ?: "Backup konnte nicht importiert werden."
clearBackupPreview()
return@launch
}
// ---- Persistieren ----
runCatching {
if (toursAndWaypoints) {
// v2.7.21 — Backup-Restore ist ein forcierter Vollersatz; Preservation-Guard
// nicht anwenden (mergedWaypoints kann leer sein bei leeren Backups).
repository.saveAllForced(payload.mergedWaypoints)
if (payload.mergedTours != existingTours) {
repository.saveTours(payload.mergedTours)
}
payload.tourDefaultsToWrite.forEach { (tour, def) ->
repository.saveTourDefaults(tour, def)
}
// v2.4.6 — Tourrouten aus dem Import zusammenführen (kein Überschreiben).
if (payload.tourRoutesToWrite.isNotEmpty()) {
repository.mergeTourRoutes(payload.tourRoutesToWrite)
}
// v2.5.5 — Tour-Cover aus dem Import zusammenführen (kein Überschreiben).
if (payload.tourCoversToWrite.isNotEmpty()) {
repository.mergeTourCovers(payload.tourCoversToWrite)
}
}
if (trackDrafts) {
// v2.5.48 FIX: Echte TrackDraft-Objekte importieren statt gpsTracks-Map.
// trackDraftsToAdd enthält bereits neue UUIDs (vergeben im ImportManager).
payload.trackDraftsToAdd.forEach { draft ->
repository.addTrackDraft(draft)
}
// tracksToWrite ist für die Live-GPS-Track-Map (gpsTracksFlow).
// Bei neuem Format leer; bei altem Format auch leer (kein Import).
payload.tracksToWrite.forEach { (tour, points) ->
repository.saveGpsTrack(tour, points)
}
}
if (mediaAndAtmo) {
payload.musicToWrite.forEach { (tour, s) ->
musicStore.save(tour, s)
}
}
// _tourList sofort aktualisieren (combine sorgt für nachgezogene Konsistenz).
if (toursAndWaypoints) {
_tourList.value = payload.mergedTours
_waypoints.value = payload.mergedWaypoints
}
// Optional: zu importierter neuer Tour wechseln, falls vorhanden.
payload.newTourSelection?.let { selectTour(it) }
}.onFailure { e ->
_errorMessage.value = "Fehler beim Anwenden des Backups: ${e.localizedMessage ?: e.javaClass.simpleName}"
clearBackupPreview()
return@launch
}
// Erfolgsmeldung zusammenstellen
val parts = mutableListOf<String>()
if (toursAndWaypoints) {
parts.add("Touren: ${payload.toursImported} (davon ${payload.toursRenamed} umbenannt)")
parts.add("Wegpunkte: ${payload.waypointsImported}")
if (payload.tourDefaultsImported > 0)
parts.add("Tour-Vorgaben: ${payload.tourDefaultsImported}")
if (payload.tourRoutesImported > 0)
parts.add("Tourrouten: ${payload.tourRoutesImported}")
if (payload.tourCoversImported > 0)
parts.add("Tour-Cover: ${payload.tourCoversImported}")
}
if (trackDrafts) parts.add("Track-Entwürfe: ${payload.tracksImported}")
if (mediaAndAtmo) {
parts.add("Begleitmusik: ${payload.musicSettingsImported}")
parts.add("Mediendateien: ${payload.mediaExtracted}")
}
if (settings && payload.trackMinDistanceMeters != null) {
parts.add("Einstellungen: GPS-Mindestabstand ${payload.trackMinDistanceMeters} m vermerkt (read-only)")
}
val warningText = if (payload.warnings.isEmpty()) "" else
"\n\nHinweise:\n" + payload.warnings.joinToString("\n")
_infoMessage.value =
"Backup-Import abgeschlossen.\n" + parts.joinToString("\n", prefix = "") + warningText
clearBackupPreview()
}
}
// ---------------------------------------------------------------------------
// GPS-Position für neuen Wegpunkt
// ---------------------------------------------------------------------------
@SuppressLint("MissingPermission")
fun fetchCurrentLocation() {
viewModelScope.launch {
_locationLoading.value = true
_currentLocation.value = null
val ctx = getApplication<Application>()
val fineGranted = ContextCompat.checkSelfPermission(
ctx, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(
ctx, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
_errorMessage.value =
"Standortberechtigung fehlt. Bitte erteilen Sie die Berechtigung \"Genauer Standort\" in den App-Einstellungen."
_locationLoading.value = false
return@launch
}
val locationManager = ctx.getSystemService(android.content.Context.LOCATION_SERVICE)
as LocationManager
val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
val networkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
runCatching {
var location: android.location.Location? = null
if (gpsEnabled || networkEnabled) {
val cts = CancellationTokenSource()
val priority = if (fineGranted)
Priority.PRIORITY_HIGH_ACCURACY
else
Priority.PRIORITY_BALANCED_POWER_ACCURACY
runCatching {
withTimeout(15_000L) {
location = fusedLocationClient
.getCurrentLocation(priority, cts.token)
.await()
}
}.onFailure {
location = null
}
}
if (location == null) {
runCatching {
location = fusedLocationClient.lastLocation.await()
}
}
if (location != null) {
_currentLocation.value = Pair(location!!.latitude, location!!.longitude)
} else if (!gpsEnabled && !networkEnabled) {
_errorMessage.value =
"GPS ist deaktiviert. Bitte aktivieren Sie den Standortdienst " +
"in den Systemeinstellungen und versuchen Sie es erneut."
} else {
_errorMessage.value =
"GPS-Position konnte nicht ermittelt werden. " +
"Bitte stellen Sie sicher, dass GPS aktiviert ist und " +
"versuchen Sie es im Freien erneut."
}
}.onFailure { e ->
_errorMessage.value = "Standortfehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
}
_locationLoading.value = false
}
}
fun clearCurrentLocation() {
_currentLocation.value = null
}
// ---------------------------------------------------------------------------
// Manuelle Wiedergabe
// ---------------------------------------------------------------------------
/**
* Returns the playlist of waypoints eligible for manual playback:
* active waypoints in the selected tour that have at least one active
* audio clip (v2.5.12).
*
* v2.5.14: Diese Funktion bleibt als Convenience-Sicht für UI-Komponenten
* erhalten, die nur die Wegpunkt-Ebene brauchen (z. B. der ManualPlayerBar-
* Counter). Die *eigentliche* Wiedergabe-Sequenz wird flach über
* [PlayableItem] mit [playableSequence] geliefert und enthält pro Wegpunkt
* einen Eintrag *je aktivem Clip*.
*/
fun manualPlaylist(): List<Waypoint> {
val tour = _selectedTour.value
return _waypoints.value.filter { wp ->
wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour &&
wp.isActive &&
wp.activeClips().isNotEmpty()
}
}
/**
* v2.5.14 — Flache Wiedergabe-Sequenz der aktuellen Tour.
*
* Pro aktivem Wegpunkt expandiert in seine aktiven Clips. Diese Liste ist
* die *einzige Wahrheit* für Tour-Play, ManualPlayerBar-Skip-Buttons, und
* Vor/Zurück im Clip-Detail-Sheet. Die Liste wird bei jedem Aufruf frisch
* aus dem aktuellen Waypoint-State gebaut — kein veralteter Snapshot.
*/
fun playableSequence(): List<PlayableItem> =
manualPlaylist().toPlayableSequence()
/**
* Toggle play/pause for the global manual player bar (playlist mode).
* - If single-item mode is active, it is stopped first and playlist mode takes over.
* - If nothing is selected, starts the first playable item of the tour.
* - If playing → pauses.
* - If paused → resumes.
*/
fun manualPlayPause() {
val sequence = playableSequence()
if (sequence.isEmpty()) return
if (pttManager.pttActive.value) {
_errorMessage.value = "PTT ist aktiv Wiedergabe wird nach dem PTT-Ende fortgesetzt."
return
}
// Beim Wechsel aus Single-Mode in Playlist-Mode: Single sauber abbauen.
if (manualPlayer.isSingleMode) {
manualPlayer.stop()
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
}
if (manualPlayer.isPlaying) {
manualPlayer.pause()
_manualPlaying.value = false
stopProgressTicker(keepSnapshot = true)
musicManager.afterWaypointAudio()
return
}
// Anker für "wo wir wieder einsteigen": entweder der zuletzt aktive
// Playable, sonst Anfang.
val current = _currentPlayable.value
val seqIdx = current
?.let { p -> sequence.indexOfFirst { it.compoundId == p.compoundId } }
?.takeIf { it >= 0 }
?: _manualIndex.value.let { wpIdx ->
if (wpIdx < 0) 0
else sequence.indexOfFirst { it.waypointIndex == wpIdx + 1 }
.let { if (it < 0) 0 else it }
}
startPlayableAt(sequence, seqIdx)
}
/**
* v2.5.14 — Skip zum vorherigen Playable: bevorzugt voriger Clip im selben
* Wegpunkt, sonst letzter Clip des vorherigen Wegpunkts. Springt am Anfang
* der Tour an das Ende der Sequenz (Wrap-around, konsistent mit dem
* bisherigen Verhalten).
*/
fun manualPrevious() {
val sequence = playableSequence()
if (sequence.isEmpty()) return
if (manualPlayer.isSingleMode) {
manualPlayer.stop()
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
}
val curIdx = currentSequenceIndex(sequence)
val newIdx = if (curIdx <= 0) sequence.size - 1 else curIdx - 1
startPlayableAt(sequence, newIdx)
}
/**
* v2.5.14 — Skip zum nächsten Playable: bevorzugt nächster Clip desselben
* Wegpunkts, sonst erster Clip des nächsten Wegpunkts. Am Ende der Sequenz
* wird umgebrochen.
*/
fun manualNext() {
val sequence = playableSequence()
if (sequence.isEmpty()) return
if (manualPlayer.isSingleMode) {
manualPlayer.stop()
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
}
val curIdx = currentSequenceIndex(sequence)
val newIdx = if (curIdx >= sequence.size - 1) 0 else curIdx + 1
startPlayableAt(sequence, newIdx)
}
/**
* v2.5.14 — Bestimmt den Index in [sequence], der zum aktuell geladenen
* Playable passt. Fallback: voriger Wegpunkt-Index → erster Clip dieses
* Wegpunkts; sonst 0.
*/
private fun currentSequenceIndex(sequence: List<PlayableItem>): Int {
val current = _currentPlayable.value
if (current != null) {
val byCompound = sequence.indexOfFirst { it.compoundId == current.compoundId }
if (byCompound >= 0) return byCompound
val byWaypoint = sequence.indexOfFirst { it.waypointId == current.waypointId }
if (byWaypoint >= 0) return byWaypoint
}
val wpIdx = _manualIndex.value
if (wpIdx >= 0) {
val match = sequence.indexOfFirst { it.waypointIndex == wpIdx + 1 }
if (match >= 0) return match
}
return 0
}
/**
* v2.5.14 — Startet die Wiedergabe in der Tour-Sequenz an Position
* [seqIndex]. Spielt anschließend automatisch weiter (Clip→Clip→Wegpunkt→
* …) bis zum Ende der Sequenz; PTT, gelöschte Wegpunkte und nachträglich
* deaktivierte Clips werden beim Auto-Advance jedes Mal neu evaluiert.
*/
private fun startPlayableAt(sequence: List<PlayableItem>, seqIndex: Int) {
if (sequence.isEmpty()) return
// v2.7.37 — Jede neue Playback-Entscheidung (Skip/Play/Resume) bricht
// eine evtl. laufende Auto-Advance-Stille ab, damit kein verspäteter
// Start den frisch gewählten Clip überschreibt.
cancelManualGap()
val safeIdx = seqIndex.coerceIn(0, sequence.size - 1)
val item = sequence[safeIdx]
_manualIndex.value = item.waypointIndex - 1
_currentPlayable.value = item
_manualPlaying.value = true
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
musicManager.beforeWaypointAudio()
playFromCurrentPlayable(autoAdvance = true)
}
/**
* v2.5.14 — Spielt den aktuell in [_currentPlayable] hinterlegten Playable
* über den ManualAudioPlayer ab.
*
* Wenn [autoAdvance] = true, wird beim natürlichen Ende automatisch zum
* nächsten Eintrag der Tour-Sequenz weitergesprungen. Bei false bleibt der
* Player nach Ende des Clips stehen (Single-Verhalten).
*/
private fun playFromCurrentPlayable(autoAdvance: Boolean) {
val ctx = getApplication<Application>()
val item = _currentPlayable.value ?: return
val title = item.displayTitle
_manualCurrentName.value = title
manualPlayer.setNextNotificationMeta(
title = title,
tour = _selectedTour.value
)
manualPlayer.play(ctx, item.uri, onError = {
// v2.7.38 — Wiedergabe konnte nicht starten (z. B. Samsung-SAF-/
// Codec-Fehler). Zustand sauber zurücksetzen statt "spielt" ohne
// Ton anzuzeigen, und den Nutzer informieren.
cancelManualGap()
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
_errorMessage.value = "Audio konnte nicht abgespielt werden. Bitte erneut versuchen."
}) {
if (!autoAdvance) {
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
return@play
}
// PTT-Sperre respektieren: keinen neuen Clip starten.
if (pttManager.pttActive.value) {
_manualPlaying.value = false
stopProgressTicker(keepSnapshot = true)
return@play
}
val nextSeq = playableSequence()
val curIdx = nextSeq.indexOfFirst { it.compoundId == item.compoundId }
val nextIdx = curIdx + 1
if (curIdx < 0 || nextIdx >= nextSeq.size) {
// Ende der Tour-Sequenz oder Wegpunkt zwischenzeitlich entfernt.
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
return@play
}
val nextItem = nextSeq[nextIdx]
// v2.7.39 — Keine feste Stille-Pause mehr: Der nächste Eintrag wird
// direkt gestartet. Dadurch gibt es keinen verzögerten Job mehr, der
// auf Samsung-Geräten oder nach Tour-/GPS-State-Wechseln in einen
// veralteten Player-Zustand feuern könnte.
if (!_manualPlaying.value || manualPlayer.isSingleMode) {
return@play
}
val seqNow = playableSequence()
val resolvedIdx = seqNow.indexOfFirst { it.compoundId == nextItem.compoundId }
if (resolvedIdx < 0) {
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
return@play
}
val resolved = seqNow[resolvedIdx]
_currentPlayable.value = resolved
_manualIndex.value = resolved.waypointIndex - 1
playFromCurrentPlayable(autoAdvance = true)
}
startProgressTicker()
}
/**
* Play a specific waypoint by its position in the current waypoint-level
* playlist. Spielt die volle Clip-Sequenz dieses Wegpunkts und läuft danach
* (Tour-Play-Verhalten) in die nächsten Wegpunkte weiter.
*/
fun manualPlayAt(index: Int) {
val playlist = manualPlaylist()
if (index !in playlist.indices) return
val sequence = playableSequence()
if (sequence.isEmpty()) return
val targetWp = playlist[index]
val seqIdx = sequence.indexOfFirst { it.waypointId == targetWp.id }
if (seqIdx < 0) return
startPlayableAt(sequence, seqIdx)
}
/** Stop manual playback completely (both playlist and single mode). */
fun manualStop() {
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
_currentPlayable.value = null
_manualCurrentName.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
}
// ---------------------------------------------------------------------------
// Per-Waypoint Play (full clip sequence, single waypoint scope)
// ---------------------------------------------------------------------------
/**
* v2.5.14 — "Karten-/Listen-Play": Spielt die **volle aktive Cliplist
* dieses Wegpunkts** als Sequenz ab. Nach dem letzten Clip wird wie im
* Tour-Play in den nächsten Wegpunkt der Sequenz weitergesprungen.
*
* Toggle-Verhalten: wenn dieser Wegpunkt bereits in der Sequenz aktiv
* abspielt, pausiert/resumiert der Aufruf den Player. Andernfalls springt
* die Wiedergabe auf den ersten Clip dieses Wegpunkts.
*
* Diese Methode ersetzt das bisherige reine "Primary-Clip"-Verhalten der
* v2.5.13 — siehe Handoff.
*/
fun manualPlaySingle(waypoint: Waypoint) {
if (pttManager.pttActive.value) {
_errorMessage.value = "PTT ist aktiv Wiedergabe wird nach dem PTT-Ende fortgesetzt."
return
}
val sequence = playableSequence()
val seqIdx = sequence.indexOfFirst { it.waypointId == waypoint.id }
if (seqIdx < 0) return
// Toggle, wenn dieser Wegpunkt aktuell in der Sequenz spielt
// (egal an welchem Clip).
val current = _currentPlayable.value
val isThisWaypointLoaded = current?.waypointId == waypoint.id && !manualPlayer.isSingleMode
if (isThisWaypointLoaded && manualPlayer.hasTrack) {
if (manualPlayer.isPlaying) {
manualPlayer.pause()
_manualPlaying.value = false
stopProgressTicker(keepSnapshot = true)
musicManager.afterWaypointAudio()
} else {
manualPlayer.resume()
_manualPlaying.value = true
startProgressTicker()
musicManager.beforeWaypointAudio()
}
return
}
startPlayableAt(sequence, seqIdx)
}
/**
* v2.5.12/14 — Spielt einen *bestimmten* Clip eines Wegpunkts als Single-
* Mode (kein Auto-Advance). Aufruf aus der Clip-Liste im Edit-Dialog.
*
* Verhalten:
* - bricht Tour-Play ab,
* - toggelt Pause/Resume, wenn dieselbe (waypoint, clip)-Kombination
* bereits läuft (ID-Schlüssel = "<waypointId>#<clipId>"),
* - klärt Status nach natürlichem Ende.
*/
fun manualPlaySingleClip(waypoint: Waypoint, clipUri: String, clipId: String, clipTitle: String) {
val ctx = getApplication<Application>()
if (clipUri.isBlank()) return
if (pttManager.pttActive.value) {
_errorMessage.value = "PTT ist aktiv Wiedergabe wird nach dem PTT-Ende fortgesetzt."
return
}
val compoundId = "${waypoint.id}#$clipId"
val wpName = waypoint.name.ifBlank { "Wegpunkt" }
val displayName = if (clipTitle.isBlank()) wpName else "$wpName · $clipTitle"
// v2.7.37 — Einzelclip-Vorschau übernimmt: eine evtl. laufende
// Auto-Advance-Stille der Tour-Sequenz abbrechen, sonst würde der
// verzögerte Start die Vorschau überschreiben.
cancelManualGap()
// Tour-Play beenden, falls aktiv.
if (_manualPlaying.value && !manualPlayer.isSingleMode) {
manualPlayer.stop()
_manualPlaying.value = false
_currentPlayable.value = null
musicManager.afterWaypointAudio()
}
val sameClipActive = manualPlayer.isSingleMode &&
manualPlayer.singleWaypointId == compoundId
if (sameClipActive) {
if (manualPlayer.isPlaying) {
manualPlayer.playSingle(ctx, compoundId, clipUri) { /* pause toggle */ }
_singlePlayingActive.value = false
stopProgressTicker(keepSnapshot = true)
musicManager.afterWaypointAudio()
} else {
manualPlayer.playSingle(ctx, compoundId, clipUri) {
_singlePlayingActive.value = false
_singlePlayingWaypointId.value = null
_manualCurrentName.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
}
_singlePlayingActive.value = true
startProgressTicker()
musicManager.beforeWaypointAudio()
}
return
}
musicManager.beforeWaypointAudio()
_singlePlayingWaypointId.value = compoundId
_singlePlayingActive.value = true
_manualCurrentName.value = displayName
manualPlayer.setNextNotificationMeta(
title = displayName,
tour = _selectedTour.value
)
manualPlayer.playSingle(ctx, compoundId, clipUri) {
_singlePlayingWaypointId.value = null
_singlePlayingActive.value = false
_manualCurrentName.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
}
startProgressTicker()
}
// ---------------------------------------------------------------------------
// v2.5.14 — Fortschritts-Polling
// ---------------------------------------------------------------------------
/**
* Startet einen periodischen Ticker, der [playbackProgress] aktualisiert,
* solange tatsächlich Audio abgespielt wird. Mehrfach-Aufrufe sind sicher
* (idempotent — der vorhandene Job läuft weiter).
*/
private fun startProgressTicker() {
if (progressJob?.isActive == true) return
progressJob = viewModelScope.launch {
while (true) {
val playing = manualPlayer.isPlaying
val pos = manualPlayer.getCurrentPositionMs()
val dur = manualPlayer.getDurationMs()
_playbackProgress.value = PlaybackProgress(
positionMs = pos,
durationMs = dur,
isPlaying = playing,
)
if (!playing && !manualPlayer.hasTrack) {
// Player wurde freigegeben → Ticker beenden.
break
}
delay(PROGRESS_TICK_MS)
}
}
}
/**
* Beendet den Fortschritts-Ticker. [keepSnapshot]=false setzt den Stand
* zurück auf [PlaybackProgress.EMPTY], damit die UI nach einem Stop nicht
* eine eingefrorene Restzeit zeigt; [keepSnapshot]=true behält die letzte
* Position (z. B. bei Pause).
*/
private fun stopProgressTicker(keepSnapshot: Boolean) {
progressJob?.cancel()
progressJob = null
if (!keepSnapshot) {
_playbackProgress.value = PlaybackProgress.EMPTY
} else {
_playbackProgress.value = _playbackProgress.value.copy(isPlaying = false)
}
}
// ---------------------------------------------------------------------------
// Begleitmusik
// ---------------------------------------------------------------------------
/** Lädt Begleitmusik-Einstellungen für die aktuelle Tour. */
private suspend fun loadMusicSettingsForCurrentTour() {
runCatching {
val settings = musicStore.settingsForTour(_selectedTour.value).first()
_musicSettings.value = settings
_musicError.value = null
// force=true, damit nach Tour-Wechsel und nach Speichern immer aktuell geladen wird
musicManager.loadTour(_selectedTour.value, force = true)
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Musik-Einstellungen", e)
}
}
// ---------------------------------------------------------------------------
// Tour-Zähler (Massen-Aktionen)
// ---------------------------------------------------------------------------
/**
* Setzt den Abspiel-Zähler (playCount) aller Wegpunkte der aktuellen Tour auf 0 zurück.
* Zeigt eine Bestätigung via [errorMessage] (als Erfolgsmeldung).
*/
fun resetTourPlayCounts() {
val tour = _selectedTour.value
viewModelScope.launch {
runCatching {
val count = repository.resetPlayCountsForTour(tour)
if (count > 0) {
// Optimistically update in-memory list
_waypoints.value = _waypoints.value.map { wp ->
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour)
wp.copy(playCount = 0)
else wp
}
}
}.onFailure { e ->
_errorMessage.value = "Fehler beim Zurücksetzen der Zähler: ${e.localizedMessage}"
}
}
}
/**
* Wendet einen Tour-weiten Abspiel-Modus auf alle Wegpunkte der aktuellen Tour an
* UND speichert die Einstellung als Tour-Vorgabe für neue Wegpunkte.
*
* @param mode Gewünschter Abspiel-Modus
* @param maxPlayCount Maximale Anzahl (nur für LIMITED_COUNT, sonst ignoriert)
* @param resetCounts true = playCount aller Wegpunkte auf 0 setzen
*/
fun applyTourPlaybackMode(mode: PlaybackMode, maxPlayCount: Int?, resetCounts: Boolean) {
val tour = _selectedTour.value
viewModelScope.launch {
runCatching {
repository.applyPlaybackModeForTour(tour, mode, maxPlayCount, resetCounts)
// Optimistically update in-memory list so UI updates immediately
_waypoints.value = _waypoints.value.map { wp ->
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour) {
wp.copy(
playbackMode = mode,
maxPlayCount = if (mode == PlaybackMode.LIMITED_COUNT) maxPlayCount else null,
playCount = if (resetCounts) 0 else wp.playCount
)
} else wp
}
// ★ Neu: Vorgaben für neue Wegpunkte speichern
val defaults = TourPlaybackDefaults(
tourName = tour,
playbackMode = mode,
maxPlayCount = maxPlayCount ?: 3
)
repository.saveTourDefaults(tour, defaults)
_tourDefaults.value = defaults
}.onFailure { e ->
_errorMessage.value = "Fehler beim Anwenden des Abspiel-Modus: ${e.localizedMessage}"
}
}
}
/**
* Lädt die Tour-weiten Abspiel-Vorgaben für die aktuell ausgewählte Tour.
*/
private suspend fun loadTourDefaultsForCurrentTour() {
runCatching {
val defaults = repository.getTourDefaults(_selectedTour.value)
_tourDefaults.value = defaults
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Tour-Vorgaben", e)
}
}
/**
* Erzeugt einen neuen Wegpunkt-Vorschlag mit den aktuellen Tour-Vorgaben.
* Wird vom MapScreen und WaypointListScreen für den FAB-Dialog verwendet.
*/
fun newWaypointWithDefaults(
lat: Double = 0.0,
lng: Double = 0.0,
tour: String = _selectedTour.value
): Waypoint {
val defaults = _tourDefaults.value
return Waypoint(
latitude = lat,
longitude = lng,
tourName = tour.ifBlank { Waypoint.DEFAULT_TOUR_NAME },
playbackMode = defaults.playbackMode,
maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT)
defaults.maxPlayCount else null,
playCount = 0
)
}
/**
* Speichert Begleitmusik-Einstellungen für die aktuelle Tour und
* wendet sie direkt auf den Player an.
*/
fun saveMusicSettings(settings: TourAudioSettings) {
_musicSettings.value = settings
_musicError.value = null
musicManager.saveAndApplySettings(_selectedTour.value, settings)
}
/** Startet/Pausiert die Begleitmusik (Test-Button im Dialog / Karte). */
fun toggleMusicPlayback() {
val settings = _musicSettings.value
if (!settings.enabled) {
_musicError.value = "Begleitmusik ist nicht aktiviert."
return
}
// Prüfen ob Quelle konfiguriert
val sourceOk = when (settings.sourceType) {
de.waypointaudio.data.MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty()
de.waypointaudio.data.MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty()
}
if (!sourceOk) {
_musicError.value = "Keine Musikquelle konfiguriert. Bitte zuerst Begleitmusik einrichten."
return
}
_musicError.value = null
if (musicManager.player.isPlaying) {
musicManager.pauseMusic()
_musicPlaying.value = false
} else {
// Einstellungen immer neu laden vor Play
musicManager.player.loadSettings(settings)
musicManager.startMusic()
_musicPlaying.value = true
}
}
/** Stoppt die Begleitmusik. */
fun stopMusicPlayback() {
musicManager.stopMusic()
_musicPlaying.value = false
_musicError.value = null
}
/** Springt zum nächsten Titel (nur lokale Playlists). */
fun musicNext() {
musicManager.nextTrack()
}
/** Springt zum vorherigen Titel (nur lokale Playlists). */
fun musicPrevious() {
musicManager.previousTrack()
}
/** Löscht den Musik-Fehler. */
fun clearMusicError() {
_musicError.value = null
}
// ---------------------------------------------------------------------------
// GPS-Track-Aufzeichnung (delegiert an TrackRecordingService via Manager)
// ---------------------------------------------------------------------------
/**
* Fügt einen neuen Track-Punkt hinzu (Legacy-Methode, jetzt via Manager).
* Wird weiterhin unterstützt falls extern aufgerufen.
*/
fun addTrackPoint(lat: Double, lng: Double) {
TrackRecordingManager.addPoint(lat, lng)
}
/**
* Startet die Track-Aufzeichnung via TrackRecordingService (Foreground Service).
* Der Service läuft weiter wenn die App in den Hintergrund wechselt.
*/
fun startTrackRecording() {
val ctx = getApplication<Application>()
val tour = _selectedTour.value
TrackRecordingManager.startRecording(ctx, tour)
}
/**
* Stoppt die Aufzeichnung und persistiert den Track.
* Der Service übernimmt die Persistierung vor dem Stoppen.
*
* Zusätzlich wird der aufgezeichnete Track als NEUER, eigenständiger
* TrackDraft im Archiv gespeichert (mit eigener UUID). Bestehende Drafts
* werden NIE überschrieben.
*/
fun stopTrackRecording() {
val ctx = getApplication<Application>()
val track = TrackRecordingManager.currentTrack.value
val tour = _selectedTour.value
// Zuerst Manager/Service stoppen
TrackRecordingManager.stopRecording(ctx)
// Auch ViewModel-seitig persistieren (als Backup für „aktueller Track")
if (track.isNotEmpty()) {
viewModelScope.launch {
runCatching {
repository.saveGpsTrack(tour, track)
_persistedTrack.value = track
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern des GPS-Tracks", e)
}
// Zusätzlich: als eigenständigen Draft archivieren
runCatching {
val draft = TrackDraft(
name = defaultDraftName(tour),
sourceTourName = tour,
points = track
)
repository.addTrackDraft(draft)
_lastSavedDraftId.value = draft.id
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Archivieren des Drafts", e)
}
}
}
}
/** Erzeugt einen Standardnamen für einen Draft (Tour + Datum/Uhrzeit). */
private fun defaultDraftName(tour: String): String {
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
return "$tour · ${fmt.format(Date())}"
}
/** Setzt das „letzter Draft"-Flag zurück (UI hat darauf reagiert). */
fun consumeLastSavedDraftId() {
_lastSavedDraftId.value = null
}
// ---------------------------------------------------------------------------
// Track-Draft-Verwaltung
// ---------------------------------------------------------------------------
/** Speichert einen neuen oder geänderten Draft (mit aktualisiertem Timestamp). */
fun saveDraft(draft: TrackDraft) {
viewModelScope.launch {
runCatching {
repository.upsertTrackDraft(draft.copy(updatedAt = System.currentTimeMillis()))
}.onFailure { e ->
_errorMessage.value = "Fehler beim Speichern des Drafts: ${e.localizedMessage}"
}
}
}
/** Löscht einen Draft. */
fun deleteDraft(draftId: String) {
viewModelScope.launch {
runCatching {
repository.deleteTrackDraft(draftId)
}.onFailure { e ->
_errorMessage.value = "Fehler beim Löschen des Drafts: ${e.localizedMessage}"
}
}
}
/**
* Importiert die gewählten Punkte eines Drafts als Wegpunkte in eine Zieltour.
*
* Wenn [draft.markedIndexes] nicht leer ist: nur diese Punkte werden importiert.
* Andernfalls werden ALLE Punkte importiert.
*
* Der Draft selbst bleibt erhalten und kann weiterhin bearbeitet werden.
*
* @return Anzahl tatsächlich importierter Wegpunkte
*/
fun importDraftAsWaypoints(draft: TrackDraft, targetTour: String): Int {
if (isRecording.value) {
_errorMessage.value = "Während der Aufzeichnung kann nicht importiert werden."
return 0
}
val target = targetTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val indexes = if (draft.markedIndexes.isNotEmpty())
draft.markedIndexes.sorted()
else
draft.points.indices.toList()
if (indexes.isEmpty()) return 0
val defaults = _tourDefaults.value
val baseName = draft.name.ifBlank { "Draft" }
val newWaypoints = indexes.mapIndexed { i, pIdx ->
val p = draft.points.getOrNull(pIdx) ?: return@mapIndexed null
Waypoint(
name = "$baseName #${i + 1}",
latitude = p.latitude,
longitude = p.longitude,
tourName = target,
playbackMode = defaults.playbackMode,
maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT)
defaults.maxPlayCount else null,
playCount = 0
)
}.filterNotNull()
viewModelScope.launch {
runCatching {
val current = _waypoints.value.toMutableList()
current.addAll(newWaypoints)
repository.saveAll(current)
// Tour ggf. zur Tourliste hinzufügen
val tours = _tourList.value
if (target !in tours) repository.saveTours(tours + target)
}.onFailure { e ->
_errorMessage.value = "Fehler beim Importieren: ${e.localizedMessage}"
}
}
return newWaypoints.size
}
/**
* Storyboard-MVP (v2.2.3):
* Übernimmt die vom Nutzer im Explorer festgelegte Reihenfolge ausgewählter
* Wegpunkte in eine Zieltour. Die Funktion erstellt für jeden Eintrag eine
* neue Wegpunkt-Kopie (eigene ID) in der Zieltour, sortiert nach der
* Eingabereihenfolge. Bestehende Wegpunkte der Zieltour bleiben erhalten.
*
* Es ist BEWUSST keine Straßenroutenberechnung — eine reale Routing-Engine
* wird hier nicht eingesetzt (keine API, keine Kosten). Das Storyboard
* versteht sich als konzeptionelle Tour-Zusammenstellung.
*
* @return Anzahl tatsächlich angelegter Wegpunkte
*/
fun applyStoryboardOrder(targetTour: String, orderedIds: List<String>): Int {
if (orderedIds.isEmpty()) return 0
val target = targetTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val source = _waypoints.value.associateBy { it.id }
val defaults = _tourDefaults.value
val newWaypoints = orderedIds.mapIndexedNotNull { i, id ->
val src = source[id] ?: return@mapIndexedNotNull null
Waypoint(
name = src.name.ifBlank { "Punkt ${i + 1}" } + " #${i + 1}",
latitude = src.latitude,
longitude = src.longitude,
radiusMeters = src.radiusMeters,
tourName = target,
playbackMode = defaults.playbackMode,
maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT)
defaults.maxPlayCount else null,
playCount = 0
)
}
if (newWaypoints.isEmpty()) return 0
viewModelScope.launch {
runCatching {
val current = _waypoints.value.toMutableList()
current.addAll(newWaypoints)
repository.saveAll(current)
val tours = _tourList.value
if (target !in tours) repository.saveTours(tours + target)
}.onFailure { e ->
_errorMessage.value = "Fehler beim Speichern des Storyboards: ${e.localizedMessage}"
}
}
return newWaypoints.size
}
/** Löscht den gespeicherten GPS-Track der aktuellen Tour. */
fun clearTrack() {
// Aufzeichnung stoppen falls aktiv
if (TrackRecordingManager.isRecording.value) {
val ctx = getApplication<Application>()
TrackRecordingManager.stopRecording(ctx)
}
TrackRecordingManager.clearTrack()
_persistedTrack.value = emptyList()
val tour = _selectedTour.value
viewModelScope.launch {
runCatching {
repository.clearGpsTrack(tour)
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Löschen des GPS-Tracks", e)
}
}
}
/** Lädt den persistierten Track der aktuellen Tour. */
private suspend fun loadPersistedTrackForCurrentTour() {
runCatching {
val tracks = repository.gpsTracks.first()
_persistedTrack.value = tracks[_selectedTour.value] ?: emptyList()
// v2.5.33 — Phantom-Track-Fix: Wenn keine Aufzeichnung läuft,
// muss der Manager-Track zur aktuellen Tour passen.
// Fehlt der Tour ein persistierter Track (neu erstellte Tour oder
// Tour ohne Aufzeichnung), wird der Manager-Track zurückgesetzt,
// damit kein verwaister Track einer anderen Tour auf der Karte
// als Phantom erscheint.
// v2.5.36 — Phantom-Guard verschärft: Für die neutrale Standardtour
// (DEFAULT_TOUR_NAME, kein aktives Recording) wird der Manager-Track
// NICHT mit dem persistierten Track besetzt. Ohne diesen Guard würde
// currentTrack.isNotEmpty() in visibleTrack feuern, bevor die
// isNeutralStandard-Prüfung greift, und die weiße Phantom-Linie
// würde trotz v2.5.35-Guards weiter erscheinen.
val isDefaultNeutral = _selectedTour.value == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME
&& !TrackRecordingManager.isRecording.value
if (!TrackRecordingManager.isRecording.value) {
if (_persistedTrack.value.isNotEmpty() && !isDefaultNeutral) {
// Für echte Touren: persistierten Track in Manager laden,
// aber nur wenn der Manager noch keinen eigenen hat (d. h. kein
// laufendes Stop-after-Save-Szenario).
if (TrackRecordingManager.currentTrack.value.isEmpty()) {
TrackRecordingManager.setTrack(_persistedTrack.value)
}
} else {
// Neue/leere Tour ODER neutrale Standardtour → Manager-Track leeren,
// damit kein Phantom-Track aus einer anderen Tour sichtbar bleibt.
TrackRecordingManager.clearTrack()
}
}
}.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Laden des GPS-Tracks", e)
}
}
override fun onCleared() {
super.onCleared()
WaypointAudioInterruptionCoordinator.unregister(
WaypointAudioInterruptionCoordinator.Source.MANUAL
)
progressJob?.cancel()
progressJob = null
manualPlayer.release()
}
companion object {
/**
* v2.5.14 — Polling-Intervall für den Wiedergabe-Fortschritt.
* 500 ms ist Compose-freundlich (zwei Updates pro Sekunde reichen für
* eine sauber laufende "Verbleibend mm:ss"-Anzeige) und schont den
* Akku spürbar gegenüber häufigeren Ticks.
*/
private const val PROGRESS_TICK_MS = 500L
}
}