Initial release of GPS2Audio
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
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.AudioFile
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
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.PlaybackMode
|
||||
import de.waypointaudio.data.TourPlaybackDefaults
|
||||
import de.waypointaudio.data.Waypoint
|
||||
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
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isNew = waypoint == null
|
||||
|
||||
// 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") }
|
||||
var soundUri by remember { mutableStateOf(waypoint?.soundUri ?: "") }
|
||||
var soundName by remember { mutableStateOf(waypoint?.soundName ?: "") }
|
||||
|
||||
// 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
|
||||
val audioPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
soundUri = uri.toString()
|
||||
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
|
||||
?: uri.toString().takeLast(30)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
// Audio file selection
|
||||
OutlinedButton(
|
||||
onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.AudioFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.choose_sound))
|
||||
}
|
||||
|
||||
if (soundName.isNotBlank()) {
|
||||
Text(
|
||||
text = soundName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 2
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.no_sound_selected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
onConfirm(
|
||||
(waypoint ?: Waypoint()).copy(
|
||||
name = name.trim(),
|
||||
latitude = lat!!,
|
||||
longitude = lng!!,
|
||||
radiusMeters = radius!!,
|
||||
soundUri = soundUri,
|
||||
soundName = soundName,
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user