931 lines
38 KiB
Kotlin
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"
|
|
}
|