2691 lines
119 KiB
Kotlin
2691 lines
119 KiB
Kotlin
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
|
||
|
||
}
|
||
}
|