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 (0–1439), ab der das tägliche Zeitfenster beginnt * @param allowedEndMinutes Tagesminute (0–1439), bis zu der das tägliche Zeitfenster endet * * --- v2.5.12 Multi-Clip --- * * @param audioClips Liste der Audio-Clips dieses Wegpunkts. Leer = * Migration aus Legacy-Feldern [soundUri]/[soundName] * via [effectiveClips]. Aktive Clips werden in * Reihenfolge ([AudioClip.orderIndex]) abgespielt; * inaktive werden übersprungen. */ data class Waypoint( val id: String = UUID.randomUUID().toString(), 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 = 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 { 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 = 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): 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 ) }