1165 lines
54 KiB
Kotlin
1165 lines
54 KiB
Kotlin
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“)")
|
||
}
|
||
}
|