Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -0,0 +1,930 @@
|
||||
package de.waypointaudio.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.Bookmarks
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import de.waypointaudio.R
|
||||
import de.waypointaudio.data.GpsTrackPoint
|
||||
import de.waypointaudio.data.TrackDraft
|
||||
import de.waypointaudio.util.NominatimClient
|
||||
import de.waypointaudio.util.NominatimResult
|
||||
import de.waypointaudio.viewmodel.WaypointViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import de.waypointaudio.data.MapProvider
|
||||
import de.waypointaudio.data.MapProviderStore
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import java.io.File
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Karten-basierter Editor für einen [TrackDraft].
|
||||
*
|
||||
* Interaktion:
|
||||
* - Tippen auf einen Streckenpunkt → schaltet Markierung für späteren Import
|
||||
* als Wegpunkt um.
|
||||
* - Lang drücken auf einen Streckenpunkt → wählt ihn zum Verschieben/Löschen.
|
||||
* - Lang drücken auf die Karte → verschiebt den ausgewählten Punkt dorthin.
|
||||
* - Toolbar: Alle markieren / Markierungen löschen, Umbenennen, Track löschen.
|
||||
* - FAB "In Tour übernehmen": importiert nur markierte Punkte. Ohne Markierung
|
||||
* deaktiviert mit erklärendem Hinweis.
|
||||
*
|
||||
* Während aktiver GPS-Aufzeichnung sind Bearbeiten/Importieren gesperrt.
|
||||
*
|
||||
* Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL).
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackDraftEditorScreen(
|
||||
viewModel: WaypointViewModel,
|
||||
draftId: String,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val mapProviderStore = remember { MapProviderStore(context.applicationContext) }
|
||||
val selectedMapProvider by mapProviderStore.provider.collectAsState(initial = MapProvider.DEFAULT)
|
||||
val drafts by viewModel.trackDrafts.collectAsState()
|
||||
val tourList by viewModel.tourList.collectAsState()
|
||||
val selectedTour by viewModel.selectedTour.collectAsState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
|
||||
val storedDraft = drafts.firstOrNull { it.id == draftId }
|
||||
var working by remember(draftId, storedDraft?.updatedAt) {
|
||||
mutableStateOf(storedDraft ?: TrackDraft(id = draftId))
|
||||
}
|
||||
|
||||
LaunchedEffect(storedDraft) {
|
||||
if (storedDraft == null) onNavigateBack()
|
||||
}
|
||||
|
||||
var selectedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showSearchDialog by remember { mutableStateOf(false) }
|
||||
var searchPin by remember { mutableStateOf<GeoPoint?>(null) }
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val tertiary = MaterialTheme.colorScheme.tertiary
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val markedColor = Color(0xFFFFAB00) // distinctive amber for marked points
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Configuration.getInstance().apply {
|
||||
userAgentValue = context.packageName
|
||||
osmdroidTileCache = File(context.cacheDir, "osmdroid")
|
||||
}
|
||||
}
|
||||
|
||||
var mapView by remember { mutableStateOf<MapView?>(null) }
|
||||
|
||||
fun toggleMark(idx: Int) {
|
||||
if (idx !in working.points.indices) return
|
||||
val isMarked = idx in working.markedIndexes
|
||||
val newMarked = if (isMarked)
|
||||
working.markedIndexes - idx
|
||||
else
|
||||
(working.markedIndexes + idx).distinct().sorted()
|
||||
working = working.copy(markedIndexes = newMarked)
|
||||
dirty = true
|
||||
viewModel.saveDraft(working)
|
||||
}
|
||||
|
||||
fun deleteSelected() {
|
||||
val sel = selectedIndex ?: return
|
||||
if (isRecording) return
|
||||
val pts = working.points.toMutableList()
|
||||
if (sel !in pts.indices) return
|
||||
pts.removeAt(sel)
|
||||
val newMarked = working.markedIndexes
|
||||
.filter { it != sel }
|
||||
.map { if (it > sel) it - 1 else it }
|
||||
working = working.copy(points = pts, markedIndexes = newMarked)
|
||||
dirty = true
|
||||
selectedIndex = null
|
||||
viewModel.saveDraft(working)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedMapProvider) {
|
||||
val mv = mapView ?: return@LaunchedEffect
|
||||
mv.setTileSource(selectedMapProvider.toTileSource())
|
||||
mv.invalidate()
|
||||
}
|
||||
|
||||
LaunchedEffect(working, selectedIndex, isRecording, searchPin) {
|
||||
val mv = mapView ?: return@LaunchedEffect
|
||||
drawDraftOverlays(
|
||||
mv = mv,
|
||||
draft = working,
|
||||
selectedIndex = selectedIndex,
|
||||
polyArgb = tertiary.toArgb(),
|
||||
pointArgb = primary.toArgb(),
|
||||
markedArgb = markedColor.toArgb(),
|
||||
selectedArgb = errorColor.toArgb(),
|
||||
searchPin = searchPin,
|
||||
onPointTapped = { idx -> toggleMark(idx) }
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (dirty) viewModel.saveDraft(working)
|
||||
mapView?.onDetach()
|
||||
}
|
||||
}
|
||||
|
||||
if (showRenameDialog) {
|
||||
var nameField by remember { mutableStateOf(working.name) }
|
||||
var notesField by remember { mutableStateOf(working.notes) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRenameDialog = false },
|
||||
title = { Text(stringResource(R.string.draft_edit_meta_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = nameField,
|
||||
onValueChange = { nameField = it },
|
||||
label = { Text(stringResource(R.string.draft_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = notesField,
|
||||
onValueChange = { notesField = it },
|
||||
label = { Text(stringResource(R.string.draft_notes_label)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
working = working.copy(name = nameField.trim(), notes = notesField)
|
||||
dirty = true
|
||||
viewModel.saveDraft(working)
|
||||
showRenameDialog = false
|
||||
}) { Text(stringResource(R.string.save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRenameDialog = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
DraftImportDialog(
|
||||
draft = working,
|
||||
tourList = tourList,
|
||||
preselectedTour = selectedTour,
|
||||
isRecording = isRecording,
|
||||
onConfirm = { tour ->
|
||||
viewModel.importDraftAsWaypoints(working, tour)
|
||||
showImportDialog = false
|
||||
onNavigateBack()
|
||||
},
|
||||
onDismiss = { showImportDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showSearchDialog) {
|
||||
PlaceSearchDialog(
|
||||
onDismiss = { showSearchDialog = false },
|
||||
onResultSelected = { result ->
|
||||
val gp = GeoPoint(result.latitude, result.longitude)
|
||||
searchPin = gp
|
||||
mapView?.controller?.animateTo(gp)
|
||||
mapView?.controller?.setZoom(15.0)
|
||||
showSearchDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text(stringResource(R.string.draft_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.draft_delete_confirm_msg, working.name.ifBlank { "Draft" })) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.deleteDraft(working.id)
|
||||
showDeleteConfirm = false
|
||||
onNavigateBack()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) { Text(stringResource(R.string.delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val canImport = !isRecording && working.markedCount > 0
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
working.name.ifBlank { stringResource(R.string.draft_unnamed) },
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.draft_editor_subtitle,
|
||||
working.pointCount,
|
||||
working.markedCount
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (dirty) viewModel.saveDraft(working)
|
||||
onNavigateBack()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showSearchDialog = true }) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.search_place_action)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (working.markedCount == working.pointCount) {
|
||||
working = working.copy(markedIndexes = emptyList())
|
||||
} else {
|
||||
working = working.copy(
|
||||
markedIndexes = working.points.indices.toList()
|
||||
)
|
||||
}
|
||||
dirty = true
|
||||
viewModel.saveDraft(working)
|
||||
},
|
||||
enabled = !isRecording && working.pointCount > 0
|
||||
) {
|
||||
Icon(
|
||||
if (working.markedCount == working.pointCount && working.pointCount > 0)
|
||||
Icons.Filled.DeleteSweep
|
||||
else
|
||||
Icons.Filled.Bookmarks,
|
||||
contentDescription = if (working.markedCount == working.pointCount && working.pointCount > 0)
|
||||
stringResource(R.string.draft_unmark_all)
|
||||
else
|
||||
stringResource(R.string.draft_mark_all)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showRenameDialog = true }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.draft_edit_meta_title))
|
||||
}
|
||||
IconButton(onClick = { showDeleteConfirm = true }) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.draft_delete),
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { if (canImport) showImportDialog = true },
|
||||
containerColor = if (canImport)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (canImport)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
icon = { Icon(Icons.Filled.Download, contentDescription = null) },
|
||||
text = {
|
||||
Text(
|
||||
if (working.markedCount > 0)
|
||||
stringResource(R.string.draft_import_button) + " (${working.markedCount})"
|
||||
else
|
||||
stringResource(R.string.draft_import_button)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
MapView(ctx).apply {
|
||||
setTileSource(selectedMapProvider.toTileSource())
|
||||
setMultiTouchControls(true)
|
||||
|
||||
val center = working.points.firstOrNull()?.let {
|
||||
GeoPoint(it.latitude, it.longitude)
|
||||
} ?: GeoPoint(51.1657, 10.4515)
|
||||
controller.setZoom(15.0)
|
||||
controller.setCenter(center)
|
||||
|
||||
// Long-press on map:
|
||||
// - If close to a track point: select that point
|
||||
// - Else if a point is already selected: move it there
|
||||
val ev = object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?) = false
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean {
|
||||
if (p == null || isRecording) return true
|
||||
val mvLocal = mapView ?: return true
|
||||
val nearest = nearestPointWithinPixels(
|
||||
mvLocal, working.points, p, thresholdPx = 80.0
|
||||
)
|
||||
if (nearest != null) {
|
||||
selectedIndex = if (selectedIndex == nearest) null else nearest
|
||||
return true
|
||||
}
|
||||
val sel = selectedIndex
|
||||
if (sel != null) {
|
||||
val pts = working.points.toMutableList()
|
||||
if (sel in pts.indices) {
|
||||
pts[sel] = pts[sel].copy(
|
||||
latitude = p.latitude,
|
||||
longitude = p.longitude
|
||||
)
|
||||
working = working.copy(points = pts)
|
||||
dirty = true
|
||||
viewModel.saveDraft(working)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
overlays.add(MapEventsOverlay(ev))
|
||||
|
||||
mapView = this
|
||||
drawDraftOverlays(
|
||||
mv = this,
|
||||
draft = working,
|
||||
selectedIndex = selectedIndex,
|
||||
polyArgb = tertiary.toArgb(),
|
||||
pointArgb = primary.toArgb(),
|
||||
markedArgb = markedColor.toArgb(),
|
||||
selectedArgb = errorColor.toArgb(),
|
||||
onPointTapped = { idx -> toggleMark(idx) }
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { mv -> mapView = mv }
|
||||
)
|
||||
|
||||
// Always-visible help banner at top so the gesture is discoverable
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
shadowElevation = 3.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 8.dp, start = 12.dp, end = 12.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.BookmarkBorder,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFFAB00),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.draft_editor_help),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Selection action bar — only when user long-pressed a point
|
||||
val sel = selectedIndex
|
||||
if (sel != null && sel in working.points.indices) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 88.dp, start = 12.dp, end = 12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.draft_point_selected, sel + 1, working.pointCount),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
val isMarked = sel in working.markedIndexes
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { toggleMark(sel) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isMarked)
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (isMarked) Icons.Filled.Bookmark else Icons.Filled.BookmarkBorder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
if (isMarked)
|
||||
stringResource(R.string.draft_point_unmark)
|
||||
else
|
||||
stringResource(R.string.draft_point_mark)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { deleteSelected() },
|
||||
enabled = !isRecording,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.draft_point_move_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recording warning banner
|
||||
if (isRecording) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.94f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 156.dp, start = 12.dp, end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.draft_locked_while_recording),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// "Keine Wegpunkte markiert"-Hinweis am FAB
|
||||
if (!isRecording && working.markedCount == 0 && working.pointCount > 0) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 12.dp, bottom = 88.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.draft_import_disabled_hint),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OSM attribution moved to bottom-left so the import FAB at bottom-right
|
||||
// cannot overlap the mandatory attribution badge.
|
||||
OsmAttributionBadge(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 6.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modaler Dialog: Draft als Wegpunkte importieren.
|
||||
* Nur markierte Punkte werden importiert. Ohne Markierung ist der Import deaktiviert.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DraftImportDialog(
|
||||
draft: TrackDraft,
|
||||
tourList: List<String>,
|
||||
preselectedTour: String,
|
||||
isRecording: Boolean,
|
||||
onConfirm: (tour: String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var targetTour by remember { mutableStateOf(preselectedTour) }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val markedCount = draft.markedCount
|
||||
val totalCount = draft.pointCount
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.draft_import_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (isRecording) {
|
||||
Text(
|
||||
stringResource(R.string.draft_locked_while_recording),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (markedCount > 0)
|
||||
stringResource(R.string.draft_import_hint_marked, markedCount, totalCount)
|
||||
else
|
||||
stringResource(R.string.draft_import_hint_none),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.draft_import_target_label),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Box {
|
||||
OutlinedTextField(
|
||||
value = targetTour,
|
||||
onValueChange = { targetTour = it },
|
||||
readOnly = false,
|
||||
label = { Text(stringResource(R.string.tour_name_label)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { menuExpanded = true }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false }
|
||||
) {
|
||||
tourList.forEach { t ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(t) },
|
||||
onClick = {
|
||||
targetTour = t
|
||||
menuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(targetTour.ifBlank { preselectedTour }) },
|
||||
enabled = !isRecording && markedCount > 0
|
||||
) {
|
||||
Icon(Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.draft_import_confirm, markedCount))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map overlays for draft editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private fun drawDraftOverlays(
|
||||
mv: MapView,
|
||||
draft: TrackDraft,
|
||||
selectedIndex: Int?,
|
||||
polyArgb: Int,
|
||||
pointArgb: Int,
|
||||
markedArgb: Int,
|
||||
selectedArgb: Int,
|
||||
searchPin: GeoPoint? = null,
|
||||
onPointTapped: (Int) -> Unit
|
||||
) {
|
||||
val overlays = mv.overlays
|
||||
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
|
||||
overlays.removeAll(overlays.filterIsInstance<Marker>().toSet())
|
||||
|
||||
if (draft.points.size >= 2) {
|
||||
val geoPoints = draft.points.map { GeoPoint(it.latitude, it.longitude) }
|
||||
val halo = Polyline(mv).apply {
|
||||
setPoints(geoPoints)
|
||||
outlinePaint.color = 0xFFFFFFFF.toInt()
|
||||
outlinePaint.strokeWidth = 28f
|
||||
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
|
||||
outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND
|
||||
outlinePaint.alpha = 230
|
||||
infoWindow = null
|
||||
}
|
||||
val polyline = Polyline(mv).apply {
|
||||
setPoints(geoPoints)
|
||||
outlinePaint.color = polyArgb
|
||||
outlinePaint.strokeWidth = 18f
|
||||
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
|
||||
outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND
|
||||
outlinePaint.alpha = 255
|
||||
infoWindow = null
|
||||
}
|
||||
overlays.add(halo)
|
||||
overlays.add(polyline)
|
||||
}
|
||||
|
||||
val markedSet = draft.markedIndexes.toSet()
|
||||
draft.points.forEachIndexed { idx, p ->
|
||||
val gp = GeoPoint(p.latitude, p.longitude)
|
||||
val color = when {
|
||||
idx == selectedIndex -> selectedArgb
|
||||
idx in markedSet -> markedArgb
|
||||
else -> pointArgb
|
||||
}
|
||||
val sizePx = when {
|
||||
idx == selectedIndex -> 96
|
||||
idx in markedSet -> 80
|
||||
else -> 64
|
||||
}
|
||||
val bm = makePointBitmap(sizePx, color, idx == selectedIndex, idx in markedSet)
|
||||
val marker = Marker(mv).apply {
|
||||
position = gp
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, bm)
|
||||
title = null
|
||||
snippet = null
|
||||
subDescription = null
|
||||
infoWindow = null
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onPointTapped(idx)
|
||||
true
|
||||
}
|
||||
}
|
||||
overlays.add(marker)
|
||||
}
|
||||
|
||||
if (searchPin != null) {
|
||||
val pinBm = makeSearchPinBitmapShared(96)
|
||||
val pinMarker = Marker(mv).apply {
|
||||
position = searchPin
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm)
|
||||
title = null
|
||||
snippet = null
|
||||
subDescription = null
|
||||
infoWindow = null
|
||||
setOnMarkerClickListener { _, _ -> true }
|
||||
}
|
||||
overlays.add(pinMarker)
|
||||
}
|
||||
|
||||
mv.invalidate()
|
||||
}
|
||||
|
||||
private fun makePointBitmap(
|
||||
sizePx: Int,
|
||||
argb: Int,
|
||||
selected: Boolean = false,
|
||||
marked: Boolean = false
|
||||
): android.graphics.Bitmap {
|
||||
val size = sizePx.coerceAtLeast(16)
|
||||
val bm = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bm)
|
||||
val cx = size / 2f
|
||||
val cy = size / 2f
|
||||
val outerR = size / 2f - 2f
|
||||
|
||||
val halo = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
alpha = 220
|
||||
}
|
||||
canvas.drawCircle(cx, cy, outerR, halo)
|
||||
|
||||
val fill = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = argb
|
||||
}
|
||||
canvas.drawCircle(cx, cy, outerR - size * 0.12f, fill)
|
||||
|
||||
val ring = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.STROKE
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
strokeWidth = (size * 0.10f).coerceAtLeast(3f)
|
||||
}
|
||||
canvas.drawCircle(cx, cy, outerR - size * 0.05f, ring)
|
||||
|
||||
if (selected) {
|
||||
val accent = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.STROKE
|
||||
color = 0xFF000000.toInt()
|
||||
strokeWidth = (size * 0.08f).coerceAtLeast(3f)
|
||||
}
|
||||
canvas.drawCircle(cx, cy, outerR - size * 0.20f, accent)
|
||||
val pip = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
}
|
||||
canvas.drawCircle(cx, cy, size * 0.12f, pip)
|
||||
} else if (marked) {
|
||||
// Bookmark-pip indicates marked (waypoint candidate)
|
||||
val pip = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = 0xFF000000.toInt()
|
||||
}
|
||||
canvas.drawCircle(cx, cy, size * 0.20f, pip)
|
||||
val pipInner = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
}
|
||||
canvas.drawCircle(cx, cy, size * 0.10f, pipInner)
|
||||
}
|
||||
return bm
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OSM Attribution badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun OsmAttributionBadge(modifier: Modifier = Modifier) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val text = stringResource(R.string.osm_attribution_short)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
modifier = modifier
|
||||
.clickable {
|
||||
runCatching { uriHandler.openUri("https://www.openstreetmap.org/copyright") }
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall.copy(textDecoration = TextDecoration.Underline),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of the nearest track point to [p] in screen-pixel space.
|
||||
* Returns null if no point is within [thresholdPx] pixels of the press.
|
||||
*/
|
||||
private fun nearestPointWithinPixels(
|
||||
mv: MapView,
|
||||
points: List<GpsTrackPoint>,
|
||||
p: GeoPoint,
|
||||
thresholdPx: Double
|
||||
): Int? {
|
||||
if (points.isEmpty()) return null
|
||||
val proj = mv.projection
|
||||
val pressPx = proj.toPixels(p, null)
|
||||
var bestIdx = -1
|
||||
var bestDist = Double.MAX_VALUE
|
||||
for ((idx, tp) in points.withIndex()) {
|
||||
val gp = GeoPoint(tp.latitude, tp.longitude)
|
||||
val px = proj.toPixels(gp, null)
|
||||
val dx = (px.x - pressPx.x).toDouble()
|
||||
val dy = (px.y - pressPx.y).toDouble()
|
||||
val d = sqrt(dx * dx + dy * dy)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
return if (bestIdx >= 0 && bestDist <= thresholdPx) bestIdx else null
|
||||
}
|
||||
|
||||
private fun draftDistanceMeters(points: List<GpsTrackPoint>): Double {
|
||||
if (points.size < 2) return 0.0
|
||||
val r = 6_371_000.0
|
||||
var total = 0.0
|
||||
for (i in 1 until points.size) {
|
||||
val a = points[i - 1]; val b = points[i]
|
||||
val dLat = Math.toRadians(b.latitude - a.latitude)
|
||||
val dLon = Math.toRadians(b.longitude - a.longitude)
|
||||
val s = sin(dLat / 2).pow(2) +
|
||||
cos(Math.toRadians(a.latitude)) * cos(Math.toRadians(b.latitude)) *
|
||||
sin(dLon / 2).pow(2)
|
||||
total += 2 * r * atan2(sqrt(s), sqrt(1 - s))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
fun formatDistanceMeters(points: List<GpsTrackPoint>): String {
|
||||
val d = draftDistanceMeters(points)
|
||||
return if (d >= 1000) "%.1f km".format(d / 1000.0) else "${d.toInt()} m"
|
||||
}
|
||||
Reference in New Issue
Block a user