Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt
T
2026-05-30 05:42:44 +00:00

191 lines
7.4 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.waypointaudio.data
import java.util.UUID
/**
* Abspiel-Modus für einen Wegpunkt.
*
* EVERY_ENTRY Standardverhalten: Ton bei jedem erneuten Betreten.
* ONCE Ton nur beim ersten Betreten (playCount == 0).
* LIMITED_COUNT Ton maximal [maxPlayCount] Mal insgesamt.
*/
enum class PlaybackMode {
EVERY_ENTRY,
ONCE,
LIMITED_COUNT
}
/**
* Datenmodell für einen Wegpunkt.
*
* @param id Eindeutige ID (UUID)
* @param name Anzeigename des Wegpunkts
* @param latitude Breitengrad in Dezimalgrad
* @param longitude Längengrad in Dezimalgrad
* @param radiusMeters Erkennungsradius in Metern
* @param soundUri Legacy-Single-Clip-URI (v2.5.11 und älter). In v2.5.12
* ist [audioClips] die führende Repräsentation; das
* Feld bleibt zur Rückwärtskompatibilität gefüllt
* (typischerweise = primary-Clip-URI), sodass alte
* Lese-Pfade unverändert funktionieren.
* @param soundName Legacy-Dateiname (v2.5.11 und älter). Wie [soundUri]
* aus Kompatibilitätsgründen weiter gepflegt.
* @param isActive Ob der Wegpunkt aktiv überwacht wird
* @param tourName Name der Tour/Route (Standard: "Standard").
*
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
*
* @param playbackMode Abspiel-Modus (Standard: EVERY_ENTRY = bisheriges Verhalten)
* @param maxPlayCount Maximale Abspielanzahl für LIMITED_COUNT; null = kein Limit
* @param playCount Bisherige Abspielanzahl (persistent, wird vom Service inkrementiert)
* @param scheduleEnabled Ob der Zeitplan aktiv ist
* @param scheduleStartMillis Frühestes Datum/Uhrzeit (Unix-Millisekunden), ab dem Abspielen erlaubt ist
* @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist
* @param allowedStartMinutes Tagesminute (01439), ab der das tägliche Zeitfenster beginnt
* @param allowedEndMinutes Tagesminute (01439), bis zu der das tägliche Zeitfenster endet
*
* --- v2.5.12 Multi-Clip ---
*
* @param audioClips Liste der Audio-Clips dieses Wegpunkts. Leer =
* Migration aus Legacy-Feldern [soundUri]/[soundName]
* via [effectiveClips]. Aktive Clips werden in
* Reihenfolge ([AudioClip.orderIndex]) abgespielt;
* inaktive werden übersprungen.
*/
data class Waypoint(
val id: String = UUID.randomUUID().toString(),
val name: String = "",
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val radiusMeters: Float = 50f,
val soundUri: String = "",
val soundName: String = "",
val isActive: Boolean = true,
// Tour-Zuordnung safe default "Standard" für Rückwärtskompatibilität
val tourName: String = DEFAULT_TOUR_NAME,
// Abspielregeln alle optional, Standardwerte entsprechen bisherigem Verhalten
val playbackMode: PlaybackMode = PlaybackMode.EVERY_ENTRY,
val maxPlayCount: Int? = null,
val playCount: Int = 0,
val scheduleEnabled: Boolean = false,
val scheduleStartMillis: Long? = null,
val scheduleEndMillis: Long? = null,
val allowedStartMinutes: Int? = null,
val allowedEndMinutes: Int? = null,
// v2.5.12 — Multi-Clip-Erweiterung. Default = leer, damit alte JSONs ohne
// diesen Key weiterhin laden (Gson füllt den Default ein).
val audioClips: List<AudioClip> = emptyList()
) {
companion object {
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
const val DEFAULT_TOUR_NAME = "Standard"
}
}
/**
* v2.5.12 — Liefert die *effektiv anzuwendende* Cliplist für diesen Wegpunkt.
*
* Verhalten:
* - Ist [Waypoint.audioClips] nicht leer, wird diese Liste in stabiler
* Sortierung (orderIndex, dann id) zurückgegeben.
* - Andernfalls wird sofern [Waypoint.soundUri] vorhanden ist ein
* synthetischer primary-Clip aus [Waypoint.soundUri]/[Waypoint.soundName]
* erzeugt. Damit funktionieren alle alten Wegpunkte ohne Migration
* transparent weiter.
* - Hat ein Wegpunkt weder [Waypoint.audioClips] noch [Waypoint.soundUri],
* ist die Rückgabe leer.
*
* Die ID des synthetischen Legacy-Clips wird deterministisch aus der Waypoint-
* ID abgeleitet, damit sich Skip-/UI-Zustände über mehrere Aufrufe hinweg
* stabil referenzieren lassen.
*/
fun Waypoint.effectiveClips(): List<AudioClip> {
if (audioClips.isNotEmpty()) {
return audioClips.sortedWith(
compareBy({ it.orderIndex }, { it.id })
)
}
if (soundUri.isBlank()) return emptyList()
return listOf(
AudioClip(
id = "legacy-$id",
title = soundName,
uri = soundUri,
displayFileName = soundName,
isActive = true,
isPrimary = true,
orderIndex = 0
)
)
}
/**
* Alle aktiven, abspielbaren Clips (für Tour-Play). Reihenfolge wie
* [effectiveClips]; ohne deaktivierte und ohne leere URIs.
*/
fun Waypoint.activeClips(): List<AudioClip> =
effectiveClips().filter { it.isActive && it.hasAudio }
/**
* Primärer Clip (Vorschau, Listenansicht, Legacy-Fallback).
*
* Priorität:
* 1. Erster Clip in [effectiveClips] mit isPrimary = true (sofern abspielbar).
* 2. Erster *aktiver* Clip mit URI.
* 3. Erster Clip mit URI überhaupt.
* 4. null, falls kein Audio existiert.
*/
fun Waypoint.primaryClip(): AudioClip? {
val clips = effectiveClips()
return clips.firstOrNull { it.isPrimary && it.hasAudio }
?: clips.firstOrNull { it.isActive && it.hasAudio }
?: clips.firstOrNull { it.hasAudio }
}
/**
* URI des primären Clips, oder Legacy-[Waypoint.soundUri] als Fallback.
* Praktisch für alle Stellen, die früher direkt `waypoint.soundUri` lasen.
*/
fun Waypoint.primarySoundUri(): String =
primaryClip()?.uri?.ifBlank { soundUri } ?: soundUri
/**
* Anzeigename des primären Clips, oder Legacy-[Waypoint.soundName].
*/
fun Waypoint.primarySoundName(): String =
primaryClip()?.effectiveTitle?.ifBlank { soundName } ?: soundName
/**
* v2.5.12 — Hilfsfunktion zum Setzen der Clipliste mit konsistenten
* Legacy-Spiegel-Feldern. Die Felder [Waypoint.soundUri] und
* [Waypoint.soundName] werden auf den primären Clip gesetzt, sodass
* - alte Lese-Pfade weiter den Hauptclip sehen,
* - JSON-Exports (legacy-Format) ein sinnvolles soundUri-Feld enthalten,
* - der Tour-Play (multi-clip-aware) trotzdem alle Clips kennt.
*
* orderIndex wird beim Speichern auf die Listen-Position normalisiert,
* damit die Reihenfolge stabil aus der UI übernommen werden kann.
*/
fun Waypoint.withClips(newClips: List<AudioClip>): Waypoint {
if (newClips.isEmpty()) {
return copy(audioClips = emptyList())
}
val normalized = newClips.mapIndexed { idx, c -> c.copy(orderIndex = idx) }
val primary = normalized.firstOrNull { it.isPrimary && it.hasAudio }
?: normalized.firstOrNull { it.isActive && it.hasAudio }
?: normalized.firstOrNull { it.hasAudio }
?: normalized.first()
// Genau einen Clip als primary markieren (idempotent)
val finalized = normalized.map { c ->
c.copy(isPrimary = (c.id == primary.id))
}
return copy(
audioClips = finalized,
soundUri = primary.uri,
soundName = primary.effectiveTitle
)
}