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(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(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(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, 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().toSet()) overlays.removeAll(overlays.filterIsInstance().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, 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): 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): String { val d = draftDistanceMeters(points) return if (d >= 1000) "%.1f km".format(d / 1000.0) else "${d.toInt()} m" }