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

1165 lines
54 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.ui
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import java.util.Calendar
import java.util.Locale
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.AudioClip
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.effectiveClips
import de.waypointaudio.data.withClips
import de.waypointaudio.license.LicenseFeatureAccess
import de.waypointaudio.license.LicenseStatusProvider
import java.text.SimpleDateFormat
import java.util.Date
// Display formats
private val DISPLAY_DATE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN)
private val DISPLAY_TIME_FORMAT = SimpleDateFormat("HH:mm", Locale.GERMAN)
/** Formats Long? (milliseconds) as "dd.MM.yyyy HH:mm" or empty. */
private fun Long?.toDisplayDateString(): String =
if (this == null) "" else DISPLAY_DATE_FORMAT.format(Date(this))
/** Formats Int? (day minute) as "HH:mm" or empty. */
private fun Int?.toDisplayTimeString(): String {
if (this == null) return ""
val h = this / 60
val m = this % 60
return "%02d:%02d".format(h, m)
}
/**
* Dialog for creating and editing a waypoint.
*
* @param waypoint Existing waypoint (null = create new)
* @param prefillLatLng Pre-filled GPS coordinates (lat, lon).
* Only used for new waypoints (waypoint == null).
* @param existingTours List of existing tour names for the dropdown.
* @param prefillTourName Tour to pre-select for new waypoints (e.g. currently active tab).
* @param tourDefaults Tour-wide playback defaults inherited by new waypoints.
* @param onConfirm Callback with the configured waypoint
* @param onDismiss Callback on cancel
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WaypointEditDialog(
waypoint: Waypoint?,
prefillLatLng: Pair<Double, Double>? = null,
existingTours: List<String> = emptyList(),
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
onConfirm: (Waypoint) -> Unit,
onDismiss: () -> Unit,
/**
* v2.5.15 — Optionaler Callback: kopiert den bearbeiteten Wegpunkt
* in eine andere Tour. Wenn null oder es keine alternative Tour gibt,
* wird die Aktion ausgeblendet. Der Aufrufer ist für ID-Neuvergabe
* und Persistenz zuständig (siehe [de.waypointaudio.viewmodel.WaypointViewModel.copyWaypointToTour]).
*/
onCopyToOtherTour: ((targetTour: String) -> Unit)? = null,
) {
val context = LocalContext.current
val isNew = waypoint == null
// v2.5.32 — Feature-Gate: Multi-Clip-Waypoints erfordern Profi-Zugang.
// Der Wert liest den Compose-State direkt, reagiert reaktiv auf Lizenzimport.
val licenseStatus = LicenseStatusProvider.status
val canMultiClip = LicenseFeatureAccess.canUseMultiClip(licenseStatus)
// Coordinate pre-fill: GPS position takes priority for new waypoints
val initialLat = when {
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.first)
else -> ""
}
val initialLng = when {
waypoint != null -> "%.7f".format(Locale.US, waypoint.longitude)
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.second)
else -> ""
}
// Basic field state
var name by remember { mutableStateOf(waypoint?.name ?: "") }
var latStr by remember { mutableStateOf(initialLat) }
var lngStr by remember { mutableStateOf(initialLng) }
var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") }
// v2.5.12 — Multi-Clip-Editor.
// Initial aus effectiveClips() befüllen, sodass alte Wegpunkte ohne audioClips
// ihren Legacy-soundUri als ersten Clip vorfinden.
val clips = remember {
mutableStateListOf<AudioClip>().apply {
waypoint?.effectiveClips()?.let { addAll(it) }
}
}
// Index des Clips, der durch den nächsten Picker-Treffer ersetzt werden soll.
// null = ein neuer Clip wird angehängt.
var replaceTargetIndex by remember { mutableStateOf<Int?>(null) }
// Tour name: use existing waypoint's tour, or prefill for new, fallback to default
val initialTour = when {
waypoint != null -> waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
prefillTourName.isNotBlank() -> prefillTourName
else -> Waypoint.DEFAULT_TOUR_NAME
}
var tourName by remember { mutableStateOf(initialTour) }
var tourMenuExpanded by remember { mutableStateOf(false) }
// Build the selectable tour list: existing tours + current value if not yet in list
val selectableTours = remember(existingTours, tourName) {
val base = if (existingTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else existingTours
if (tourName !in base) base + tourName else base
}
// Basic field validation errors
var nameError by remember { mutableStateOf(false) }
var latError by remember { mutableStateOf(false) }
var lngError by remember { mutableStateOf(false) }
var radiusError by remember { mutableStateOf(false) }
// --- Playback rules state inherit tour defaults for new waypoints ---
val defaultMode = if (waypoint == null) tourDefaults.playbackMode else waypoint.playbackMode
val defaultMaxCount = if (waypoint == null && tourDefaults.playbackMode == PlaybackMode.LIMITED_COUNT)
tourDefaults.maxPlayCount.toString() else waypoint?.maxPlayCount?.toString() ?: ""
var playbackMode by remember { mutableStateOf(defaultMode) }
var maxPlayCountStr by remember {
mutableStateOf(defaultMaxCount)
}
var maxPlayCountError by remember { mutableStateOf(false) }
var currentPlayCount by remember { mutableStateOf(waypoint?.playCount ?: 0) }
// Scheduler: stored as millis / minutes (nullable = not set)
var scheduleEnabled by remember { mutableStateOf(waypoint?.scheduleEnabled ?: false) }
var scheduleStartMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleStartMillis) }
var scheduleEndMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleEndMillis) }
var allowedStartMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedStartMinutes) }
var allowedEndMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedEndMinutes) }
// Schedule validation errors
var scheduleRangeError by remember { mutableStateOf(false) }
// GPS pre-fill on change
LaunchedEffect(prefillLatLng) {
if (isNew && prefillLatLng != null) {
latStr = "%.7f".format(Locale.US, prefillLatLng.first)
lngStr = "%.7f".format(Locale.US, prefillLatLng.second)
}
}
// Audio file picker — v2.5.12: erzeugt einen neuen Clip oder ersetzt
// die Audio-Quelle eines bestehenden Clips. Steuerung über [replaceTargetIndex].
val audioPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
val pickedUri = uri.toString()
val pickedName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
?: uri.toString().takeLast(30)
val tgt = replaceTargetIndex
if (tgt != null && tgt in clips.indices) {
val old = clips[tgt]
clips[tgt] = old.copy(
uri = pickedUri,
displayFileName = pickedName,
// Titel nur dann automatisch übernehmen, wenn noch keiner gesetzt war
title = old.title.ifBlank { pickedName }
)
} else {
clips.add(
AudioClip(
title = pickedName,
uri = pickedUri,
displayFileName = pickedName,
isActive = true,
isPrimary = clips.isEmpty(),
orderIndex = clips.size
)
)
}
replaceTargetIndex = null
}
}
// Helper: open DatePickerDialog followed by TimePickerDialog, result via callback
fun showDateTimePicker(
initialMillis: Long?,
onResult: (Long) -> Unit
) {
val cal = Calendar.getInstance().also { c ->
if (initialMillis != null) c.timeInMillis = initialMillis
}
DatePickerDialog(
context,
{ _, year, month, day ->
// After date is picked, open time picker
TimePickerDialog(
context,
{ _, hour, minute ->
val result = Calendar.getInstance().apply {
set(Calendar.YEAR, year)
set(Calendar.MONTH, month)
set(Calendar.DAY_OF_MONTH, day)
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
onResult(result.timeInMillis)
},
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
true // 24-hour format
).show()
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
// Helper: open TimePickerDialog, result in minutes (0..1439)
fun showTimePicker(
initialMinutes: Int?,
onResult: (Int) -> Unit
) {
val h = (initialMinutes ?: 0) / 60
val m = (initialMinutes ?: 0) % 60
TimePickerDialog(
context,
{ _, hour, minute ->
onResult(hour * 60 + minute)
},
h, m,
true // 24-hour format
).show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(if (isNew) R.string.add_waypoint else R.string.edit_waypoint)
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// GPS pre-fill notice
if (isNew && prefillLatLng != null) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
"📍 Koordinaten aus aktuellem GPS-Standort vorausgefüllt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
// Tour-Vorgaben-Hinweis für neue Wegpunkte
if (isNew && tourDefaults.playbackMode != de.waypointaudio.data.PlaybackMode.EVERY_ENTRY) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = stringResource(R.string.waypoint_inherits_tour_defaults),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it; nameError = false },
label = { Text(stringResource(R.string.waypoint_name)) },
isError = nameError,
supportingText = if (nameError) {{ Text("Pflichtfeld") }} else null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ---------------------------------------------------------------
// Tour-Zuordnung
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.tour_section_label),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// Tour dropdown selectableTours als Auswahlhilfe,
// aber freie Texteingabe ist ebenfalls möglich
ExposedDropdownMenuBox(
expanded = tourMenuExpanded,
onExpandedChange = { tourMenuExpanded = it }
) {
OutlinedTextField(
value = tourName,
onValueChange = { tourName = it },
label = { Text(stringResource(R.string.tour_name_label)) },
placeholder = { Text(Waypoint.DEFAULT_TOUR_NAME) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = tourMenuExpanded)
},
singleLine = true,
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = tourMenuExpanded,
onDismissRequest = { tourMenuExpanded = false }
) {
selectableTours.forEach { t ->
DropdownMenuItem(
text = { Text(t) },
onClick = {
tourName = t
tourMenuExpanded = false
}
)
}
}
}
Text(
text = stringResource(R.string.tour_name_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
// ---------------------------------------------------------------
// v2.5.16 — Wegpunkt kopieren (nur im Bearbeiten-Modus sichtbar)
// Direkt unter der Tour-Zuordnung, damit der Aktionsbereich
// ohne Scrollen auffindbar ist. Wird nur gezeigt, wenn der
// Aufrufer einen Copy-Callback liefert (Player/Playback nicht).
// ---------------------------------------------------------------
if (waypoint != null && onCopyToOtherTour != null) {
WaypointCopySection(
currentTour = tourName,
existingTours = existingTours,
onCopy = { target ->
onCopyToOtherTour(target)
onDismiss()
}
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Coordinates
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = latStr,
onValueChange = { latStr = it; latError = false },
label = { Text("Lat") },
isError = latError,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = lngStr,
onValueChange = { lngStr = it; lngError = false },
label = { Text("Lng") },
isError = lngError,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
// Coordinates format hint
Text(
"Dezimalgrad, z. B. 48.137154 · 11.576124",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
// Radius
OutlinedTextField(
value = radiusStr,
onValueChange = { radiusStr = it; radiusError = false },
label = { Text(stringResource(R.string.waypoint_radius)) },
isError = radiusError,
supportingText = if (radiusError) {{ Text("Ganzzahl > 0") }} else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ---------------------------------------------------------------
// v2.5.12 — Audio-Clips (Mehrfach-Auswahl + Reihenfolge)
// v2.5.32 — Multi-Clip-Gate: Hauptclip (idx 0) immer frei,
// Zusatzclips (idx > 0) nur mit Profi-Zugang bearbeiten.
// ---------------------------------------------------------------
Text(
text = "Audio-Clips",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// v2.5.32 — Profi-Hinweis: nur anzeigen, wenn Zusatzclips vorhanden
// und kein Profi-Zugang. Daten werden nicht gelöscht.
if (!canMultiClip && clips.size > 1) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = LicenseFeatureAccess.MSG_MULTI_CLIP,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
if (clips.isEmpty()) {
Text(
text = stringResource(R.string.no_sound_selected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
} else {
clips.forEachIndexed { idx, clip ->
// v2.5.32: Zusatzclips (idx > 0) sind ohne Profi-Zugang
// lesbar (Daten bleiben erhalten), aber nicht bearbeitbar.
val isExtraClip = idx > 0
val clipEditable = !isExtraClip || canMultiClip
Surface(
tonalElevation = 1.dp,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Kopfzeile: Position + (primary)-Marker
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Clip ${idx + 1}" + if (clip.isPrimary) " · Hauptclip" else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
if (clipEditable) {
// Reihenfolge nach oben / unten
IconButton(
onClick = {
if (idx > 0) {
val tmp = clips[idx - 1]
clips[idx - 1] = clips[idx]
clips[idx] = tmp
}
},
enabled = idx > 0
) {
Icon(
Icons.Filled.ArrowUpward,
contentDescription = "Nach oben",
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
if (idx < clips.size - 1) {
val tmp = clips[idx + 1]
clips[idx + 1] = clips[idx]
clips[idx] = tmp
}
},
enabled = idx < clips.size - 1
) {
Icon(
Icons.Filled.ArrowDownward,
contentDescription = "Nach unten",
modifier = Modifier.size(18.dp)
)
}
}
}
// Titel-Feld
OutlinedTextField(
value = clip.title,
onValueChange = { newTitle ->
if (clipEditable) clips[idx] = clip.copy(title = newTitle)
},
label = { Text("Titel") },
placeholder = { Text(clip.displayFileName.ifBlank { "Audio-Clip" }) },
singleLine = true,
readOnly = !clipEditable,
modifier = Modifier.fillMaxWidth()
)
// Dateiname (Quelle) als kleiner Hinweis
if (clip.displayFileName.isNotBlank()) {
Text(
text = clip.displayFileName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
maxLines = 2
)
} else if (!clip.hasAudio) {
Text(
text = "Keine Audio-Datei zugewiesen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (clipEditable) {
// Aktions-Zeile
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Aktiv-Schalter
Text(
text = "Aktiv",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
Switch(
checked = clip.isActive,
onCheckedChange = { checked ->
clips[idx] = clip.copy(isActive = checked)
}
)
// Audio ersetzen
IconButton(onClick = {
replaceTargetIndex = idx
audioPickerLauncher.launch(arrayOf("audio/*"))
}) {
Icon(
Icons.Filled.SwapHoriz,
contentDescription = "Audio ersetzen",
modifier = Modifier.size(20.dp)
)
}
// Clip löschen
IconButton(onClick = {
clips.removeAt(idx)
}) {
Icon(
Icons.Filled.Delete,
contentDescription = "Clip löschen",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
// Als Hauptclip markieren (nur sinnvoll, wenn noch nicht primary)
if (!clip.isPrimary && clip.hasAudio) {
TextButton(onClick = {
// Alle Clips zurücksetzen, dann diesen markieren
for (i in clips.indices) {
clips[i] = clips[i].copy(isPrimary = (i == idx))
}
}) {
Text(
text = "Als Hauptclip festlegen",
style = MaterialTheme.typography.bodySmall
)
}
}
} // end clipEditable
}
}
}
}
// "Neuen Clip hinzufügen" startet Picker im Anhängen-Modus.
// v2.5.32: Nur anzeigen wenn Profi-Zugang vorhanden ODER noch kein
// Clip (Hauptclip hinzufügen ist immer frei).
if (canMultiClip || clips.isEmpty()) {
OutlinedButton(
onClick = {
replaceTargetIndex = null
audioPickerLauncher.launch(arrayOf("audio/*"))
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (clips.isEmpty()) stringResource(R.string.choose_sound)
else "Weiteren Clip hinzufügen"
)
}
} else if (clips.isNotEmpty()) {
// Hauptclip-Audio-Wechsel bleibt immer möglich (index 0)
OutlinedButton(
onClick = {
replaceTargetIndex = 0
audioPickerLauncher.launch(arrayOf("audio/*"))
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.SwapHoriz,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Hauptclip-Audio wechseln")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// ---------------------------------------------------------------
// Playback rules
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.playback_rules_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// Mode selection via DropdownMenu
var modeMenuExpanded by remember { mutableStateOf(false) }
val modeLabel = when (playbackMode) {
PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry)
PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once)
PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited)
}
ExposedDropdownMenuBox(
expanded = modeMenuExpanded,
onExpandedChange = { modeMenuExpanded = it }
) {
OutlinedTextField(
value = modeLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.playback_mode_label)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded)
},
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = modeMenuExpanded,
onDismissRequest = { modeMenuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_every_entry)) },
onClick = {
playbackMode = PlaybackMode.EVERY_ENTRY
modeMenuExpanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_once)) },
onClick = {
playbackMode = PlaybackMode.ONCE
modeMenuExpanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_limited)) },
onClick = {
playbackMode = PlaybackMode.LIMITED_COUNT
modeMenuExpanded = false
}
)
}
}
// Max count only visible for LIMITED_COUNT
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
OutlinedTextField(
value = maxPlayCountStr,
onValueChange = { maxPlayCountStr = it; maxPlayCountError = false },
label = { Text(stringResource(R.string.playback_max_count_label)) },
placeholder = { Text(stringResource(R.string.playback_max_count_hint)) },
isError = maxPlayCountError,
supportingText = if (maxPlayCountError) {
{ Text(stringResource(R.string.playback_max_count_error)) }
} else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
// Counter display + reset (edit mode only)
if (!isNew) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.playback_count_info, currentPlayCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
TextButton(onClick = { currentPlayCount = 0 }) {
Text(
text = stringResource(R.string.playback_count_reset),
style = MaterialTheme.typography.bodySmall
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// ---------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.schedule_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.schedule_enabled_label),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = scheduleEnabled,
onCheckedChange = { scheduleEnabled = it }
)
}
if (scheduleEnabled) {
// ---- Start date/time ----
Text(
text = stringResource(R.string.schedule_start_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showDateTimePicker(scheduleStartMillis) { millis ->
scheduleStartMillis = millis
scheduleRangeError = false
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (scheduleStartMillis != null)
scheduleStartMillis.toDisplayDateString()
else
stringResource(R.string.schedule_pick_start),
maxLines = 1
)
}
if (scheduleStartMillis != null) {
TextButton(onClick = {
scheduleStartMillis = null
scheduleRangeError = false
}) {
Text(stringResource(R.string.schedule_clear_start))
}
}
}
// ---- End date/time ----
Text(
text = stringResource(R.string.schedule_end_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showDateTimePicker(scheduleEndMillis) { millis ->
scheduleEndMillis = millis
scheduleRangeError = false
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (scheduleEndMillis != null)
scheduleEndMillis.toDisplayDateString()
else
stringResource(R.string.schedule_pick_end),
maxLines = 1
)
}
if (scheduleEndMillis != null) {
TextButton(onClick = {
scheduleEndMillis = null
scheduleRangeError = false
}) {
Text(stringResource(R.string.schedule_clear_end))
}
}
}
// Range validation error
if (scheduleRangeError) {
Text(
text = stringResource(R.string.schedule_end_before_start_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ---- Daily time window ----
Text(
text = stringResource(R.string.schedule_daily_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Daily start time
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showTimePicker(allowedStartMinutes) { minutes ->
allowedStartMinutes = minutes
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (allowedStartMinutes != null)
allowedStartMinutes.toDisplayTimeString()
else
stringResource(R.string.schedule_pick_daily_start),
maxLines = 1
)
}
if (allowedStartMinutes != null) {
TextButton(onClick = { allowedStartMinutes = null }) {
Text(stringResource(R.string.schedule_clear_window))
}
}
}
// Daily end time
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showTimePicker(allowedEndMinutes) { minutes ->
allowedEndMinutes = minutes
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (allowedEndMinutes != null)
allowedEndMinutes.toDisplayTimeString()
else
stringResource(R.string.schedule_pick_daily_end),
maxLines = 1
)
}
if (allowedEndMinutes != null) {
TextButton(onClick = { allowedEndMinutes = null }) {
Text(stringResource(R.string.schedule_clear_window))
}
}
}
// Info: daily window can cross midnight
Text(
text = stringResource(R.string.schedule_daily_midnight_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
}
// v2.5.16 — Kopiersektion wurde in den oberen Tour-Bereich
// verschoben und ist dort gut sichtbar.
}
},
confirmButton = {
TextButton(onClick = {
// --- Basic validation ---
nameError = name.isBlank()
val lat = latStr.replace(',', '.').toDoubleOrNull()
val lng = lngStr.replace(',', '.').toDoubleOrNull()
val radius = radiusStr.replace(',', '.').toFloatOrNull()
latError = lat == null || lat !in -90.0..90.0
lngError = lng == null || lng !in -180.0..180.0
radiusError = radius == null || radius <= 0f
// --- Tour name validation: fallback to default if blank ---
val safeTourName = tourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME }
// --- Playback rule validation ---
var maxCount: Int? = null
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
maxCount = maxPlayCountStr.trim().toIntOrNull()
maxPlayCountError = (maxCount == null || maxCount < 1)
} else {
maxPlayCountError = false
}
// --- Schedule validation ---
scheduleRangeError = if (scheduleEnabled) {
scheduleStartMillis != null &&
scheduleEndMillis != null &&
scheduleEndMillis!! < scheduleStartMillis!!
} else false
val hasError = nameError || latError || lngError || radiusError ||
maxPlayCountError || scheduleRangeError
if (!hasError) {
// v2.5.12 — Clips über withClips() konsistent in audioClips +
// Legacy-soundUri/-soundName spiegeln. Filtere Clips ohne URI
// (z. B. wenn der User auf "+" geklickt, aber dann den Picker
// abgebrochen hat — solche Phantom-Clips entstehen hier zwar
// nicht, aber wir bleiben defensiv).
val cleanClips = clips.filter { it.hasAudio }
val base = (waypoint ?: Waypoint()).copy(
name = name.trim(),
latitude = lat!!,
longitude = lng!!,
radiusMeters = radius!!,
tourName = safeTourName,
// Playback rules
playbackMode = playbackMode,
maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null,
playCount = currentPlayCount,
scheduleEnabled = scheduleEnabled,
scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null,
scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null,
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null
)
val withAudio = if (cleanClips.isEmpty()) {
// Keine Clips mehr — Legacy-Felder leeren, damit kein
// Geister-Audio aus alten Daten zurückbleibt.
base.copy(audioClips = emptyList(), soundUri = "", soundName = "")
} else {
base.withClips(cleanClips)
}
onConfirm(withAudio)
}
}) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
/**
* v2.5.16 — Kopiersektion für den Bearbeiten-Dialog.
*
* Stellt zwei Aktionen bereit:
* - "In andere Tour kopieren" mit Drop-down-Auswahl (alle Touren außer der
* aktuellen).
* - "In dieser Tour duplizieren" als zweite Aktion. Hängt eine 1:1-Kopie
* ans Ende der aktuellen Tour. Auch nutzbar, wenn nur eine Tour existiert.
*
* Wird vom [WaypointEditDialog] nur eingebunden, wenn ein bestehender
* Wegpunkt bearbeitet wird und ein onCopyToOtherTour-Callback vorhanden ist
* — also nie in der Player-/Playback-Ansicht.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WaypointCopySection(
currentTour: String,
existingTours: List<String>,
onCopy: (targetTour: String) -> Unit,
) {
val effectiveCurrent = currentTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val otherTours = remember(existingTours, effectiveCurrent) {
existingTours.filter { it.isNotBlank() && it != effectiveCurrent }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "Wegpunkt kopieren",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Erzeugt eine eigenständige Kopie mit allen Clips. Das Original " +
"bleibt unverändert. Die Kopie wird ans Ende der Zieltour gehängt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)
)
if (otherTours.isNotEmpty()) {
var copyTargetMenuExpanded by remember { mutableStateOf(false) }
var copyTarget by remember(otherTours) { mutableStateOf(otherTours.first()) }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
expanded = copyTargetMenuExpanded,
onExpandedChange = { copyTargetMenuExpanded = it },
modifier = Modifier.weight(1f)
) {
OutlinedTextField(
value = copyTarget,
onValueChange = {},
readOnly = true,
label = { Text("Ziel-Tour") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = copyTargetMenuExpanded
)
},
singleLine = true,
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = copyTargetMenuExpanded,
onDismissRequest = { copyTargetMenuExpanded = false }
) {
otherTours.forEach { t ->
DropdownMenuItem(
text = { Text(t) },
onClick = {
copyTarget = t
copyTargetMenuExpanded = false
}
)
}
}
}
FilledTonalButton(onClick = { onCopy(copyTarget) }) {
Text("Kopieren")
}
}
} else {
Text(
text = "Es ist noch keine weitere Tour vorhanden. Du kannst diesen " +
"Wegpunkt aber in der aktuellen Tour „$effectiveCurrent“ duplizieren.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)
)
}
// Zweite Aktion: Duplizieren in derselben Tour. Immer verfügbar, auch
// wenn keine andere Tour existiert — deckt den Single-Tour-Fall ab.
OutlinedButton(
onClick = { onCopy(effectiveCurrent) },
modifier = Modifier.fillMaxWidth()
) {
Text("In dieser Tour duplizieren („$effectiveCurrent“)")
}
}