Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -12,9 +12,14 @@ 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.*
@@ -26,9 +31,14 @@ 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
@@ -69,11 +79,23 @@ fun WaypointEditDialog(
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
onConfirm: (Waypoint) -> Unit,
onDismiss: () -> 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)
@@ -91,8 +113,18 @@ fun WaypointEditDialog(
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 ?: "") }
// 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 {
@@ -144,7 +176,8 @@ fun WaypointEditDialog(
}
}
// Audio file picker
// 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? ->
@@ -155,9 +188,31 @@ fun WaypointEditDialog(
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
soundUri = uri.toString()
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
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
}
}
@@ -319,6 +374,23 @@ fun WaypointEditDialog(
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
@@ -362,33 +434,236 @@ fun WaypointEditDialog(
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))
// ---------------------------------------------------------------
// 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 (soundName.isNotBlank()) {
Text(
text = soundName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2
)
} else {
if (clips.isEmpty()) {
Text(
text = stringResource(R.string.no_sound_selected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
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))
@@ -702,6 +977,9 @@ fun WaypointEditDialog(
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 = {
@@ -738,26 +1016,36 @@ fun WaypointEditDialog(
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
)
// 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))
@@ -770,3 +1058,107 @@ fun WaypointEditDialog(
}
)
}
/**
* 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“)")
}
}