Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/ui/TrackDraftEditorScreen.kt
T
2026-05-30 05:42:44 +00:00

931 lines
38 KiB
Kotlin

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"
}