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? = null, existingTours: List = 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().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(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(waypoint?.scheduleStartMillis) } var scheduleEndMillis by remember { mutableStateOf(waypoint?.scheduleEndMillis) } var allowedStartMinutes by remember { mutableStateOf(waypoint?.allowedStartMinutes) } var allowedEndMinutes by remember { mutableStateOf(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, 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“)") } }