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

1133 lines
51 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.waypointaudio.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.AddLocation
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import de.waypointaudio.R
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.MapProvider
import de.waypointaudio.data.MapProviderStore
import de.waypointaudio.data.Waypoint
import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
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.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.compass.CompassOverlay
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
import java.io.File
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Karten-Editor mit osmdroid (OpenStreetMap).
*
* Funktionen:
* - Zeigt alle Wegpunkte der gewählten Tour mit Marker + Radius-Kreis.
* - Antippen eines Wegpunkt-Markers → Aktionsmenü (Bearbeiten / Löschen).
* - Langes Drücken auf die Karte → neuer Wegpunkt an dieser Position.
* - FAB „Mein Standort" → zentriert Karte, zeigt Puck-Marker.
* - FAB „Als Wegpunkt" → neuer Wegpunkt aus der ermittelten GPS-Position.
* - GPS-Track-Aufzeichnung: Start / Stopp, Polyline, Statistik (Punkte, Distanz, Zeit).
* - Track wird gespeichert und beim erneuten Öffnen der Tour wieder angezeigt.
* - „Wegpunkt am letzten Trackpunkt" erstellt neuen Wegpunkt am Track-Ende.
*
* Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL)
*/
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapScreen(
viewModel: WaypointViewModel,
onNavigateBack: () -> Unit,
// v2.2.1: Wenn gesetzt, zeigt die TopAppBar einen Explorer/Editor-Modus-
// Umschalter im Titel-Slot. Dadurch wirken Explorer und Editor als ein
// gemeinsames Kartenmodul.
currentMode: MapMode? = null,
onModeChange: ((MapMode) -> Unit)? = null
) {
val allWaypoints by viewModel.waypoints.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
val tourList by viewModel.tourList.collectAsState()
val tourDefaults by viewModel.tourDefaults.collectAsState()
val currentTrack by viewModel.currentTrack.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
// Nur Wegpunkte der gewählten Tour anzeigen
val waypoints = allWaypoints.filter {
it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour
}
val context = LocalContext.current
val primaryColor = MaterialTheme.colorScheme.primary
val errorColor = MaterialTheme.colorScheme.error
val trackColor = MaterialTheme.colorScheme.tertiary
val scope = rememberCoroutineScope()
// osmdroid konfigurieren
LaunchedEffect(Unit) {
Configuration.getInstance().apply {
userAgentValue = context.packageName
osmdroidTileCache = File(context.cacheDir, "osmdroid")
}
}
var mapView by remember { mutableStateOf<MapView?>(null) }
var myLocationMarker by remember { mutableStateOf<Marker?>(null) }
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showAddWaypointAction by remember { mutableStateOf(false) }
// Karten-Anbieter (Phase 1): persistent via DataStore, app-weit gespeichert.
// Hinweis: Eine Pro-Tour-Auswahl ist mit der bisherigen Tour-Settings-Architektur
// nicht direkt vereinbar (Touren halten Audio-Einstellungen, keine Karten-Settings),
// daher bewusst app-weit. Die Auswahl ist über das Karten-Menü jederzeit umstellbar.
val providerStore = remember { MapProviderStore(context.applicationContext) }
val selectedProvider by providerStore.provider.collectAsState(initial = MapProvider.DEFAULT)
var showProviderDialog by remember { mutableStateOf(false) }
// Editor-Dialog-State
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
// Wegpunkt-Aktionsmenü (Marker antippen)
var actionMenuWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// Single-play state for map dialog button
val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState()
// GPS-Track-Zeiterfassung
var recordingStartMs by remember { mutableLongStateOf(0L) }
var elapsedSeconds by remember { mutableLongStateOf(0L) }
// Bestätigungsdialog für das Löschen eines Wegpunkts
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// Ortssuche (Nominatim/OpenStreetMap) — temporärer roter Pin auf der Karte.
// Verändert weder Wegpunkte, Tracks, Drafts noch den Aufnahmezustand.
var showSearchDialog by remember { mutableStateOf(false) }
var searchPin by remember { mutableStateOf<GeoPoint?>(null) }
// v2.2.1: Layer-Panel-Status. Die Switches steuern aktuell vorwiegend
// Sichtbarkeit; markers/radius/track werden in drawMapOverlays gefiltert.
var layerState by remember { mutableStateOf(MapLayerState()) }
// Bitmap für Standort-Puck (einmalig erstellt)
val puckBitmap = remember { createLocationPuckBitmap(context) }
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
// ─── Karten-Anbieter Wechsel: Tile-Source aktualisieren und Cache invalidieren ─
LaunchedEffect(selectedProvider) {
val mv = mapView ?: return@LaunchedEffect
mv.setTileSource(selectedProvider.toTileSource())
mv.invalidate()
}
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben / Auswahl / Such-Pin / Layer sich ändern ─
LaunchedEffect(waypoints, primaryColor, currentTrack, actionMenuWaypoint, searchPin, layerState) {
val mv = mapView ?: return@LaunchedEffect
drawMapOverlays(
mv = mv,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
// ─── Sekunden-Timer während Aufzeichnung ────────────────────────────────
LaunchedEffect(isRecording) {
if (isRecording) {
recordingStartMs = System.currentTimeMillis()
while (isRecording) {
elapsedSeconds = (System.currentTimeMillis() - recordingStartMs) / 1000L
kotlinx.coroutines.delay(1000L)
}
}
}
// ─── GPS-Track-Aufzeichnung via Foreground Service ────────────────────
// Der TrackRecordingService läuft als Foreground Service und sendet
// GPS-Punkte an TrackRecordingManager → currentTrack / isRecording StateFlows.
// Die UI beobachtet diese Flows bereits oben via viewModel.currentTrack / isRecording.
// ─── Editor-Dialog ───────────────────────────────────────────────────────
if (showEditDialog) {
WaypointEditDialog(
waypoint = editDialogWaypoint,
prefillLatLng = if (editDialogWaypoint == null) editDialogPrefill else null,
existingTours = tourList,
prefillTourName = selectedTour,
tourDefaults = tourDefaults,
onConfirm = { newWaypoint ->
viewModel.upsert(newWaypoint)
showEditDialog = false
editDialogWaypoint = null
editDialogPrefill = null
showAddWaypointAction = false
},
onDismiss = {
showEditDialog = false
editDialogWaypoint = null
editDialogPrefill = null
},
// v2.5.16 — Kopieren auch aus dem Karten-Bearbeiten-Flow möglich.
onCopyToOtherTour = editDialogWaypoint?.let { wp ->
{ targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) }
}
)
}
// ─── Wegpunkt-Aktionsmenü ────────────────────────────────────────────────
actionMenuWaypoint?.let { wp ->
AlertDialog(
onDismissRequest = { actionMenuWaypoint = null },
title = {
Text(
text = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) },
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
// Play single button only shown when audio is assigned
if (wp.soundUri.isNotBlank()) {
val isThisLoaded = singlePlayingWaypointId == wp.id
Button(
onClick = { viewModel.manualPlaySingle(wp) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.PlayCircle,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
if (isThisLoaded)
stringResource(R.string.waypoint_pause_single)
else
stringResource(R.string.waypoint_play_single)
)
}
}
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Bearbeiten
Button(onClick = {
editDialogWaypoint = wp
editDialogPrefill = null
showEditDialog = true
actionMenuWaypoint = null
}) {
Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.map_waypoint_edit))
}
// Löschen öffnet Bestätigungsdialog
Button(
onClick = {
deleteConfirmWaypoint = wp
actionMenuWaypoint = null
},
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.map_waypoint_delete))
}
}
},
dismissButton = {
TextButton(onClick = { actionMenuWaypoint = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// ─── Bestätigungsdialog: Wegpunkt löschen ────────────────────────────────
deleteConfirmWaypoint?.let { wp ->
AlertDialog(
onDismissRequest = { deleteConfirmWaypoint = null },
title = {
Text(
text = stringResource(R.string.confirm_delete_waypoint_title),
style = MaterialTheme.typography.titleMedium
)
},
text = {
val name = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }
Text(
text = stringResource(R.string.confirm_delete_waypoint_msg, name),
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(
onClick = {
viewModel.delete(wp.id)
deleteConfirmWaypoint = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { deleteConfirmWaypoint = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// ─── Karten-Anbieter Auswahl-Dialog ─────────────────────────────────────
if (showProviderDialog) {
MapProviderDialog(
selected = selectedProvider,
onSelect = { provider ->
scope.launch {
providerStore.setProvider(provider)
}
showProviderDialog = false
},
onDismiss = { showProviderDialog = false }
)
}
// ─── Ortssuche Dialog (Nominatim/OSM) ────────────────────────────────────
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
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
if (currentMode != null && onModeChange != null) {
MapModeSegmentedHeader(
current = currentMode,
onChange = onModeChange,
subtitle = if (tourList.size > 1) selectedTour else null
)
} else {
Column {
Text(stringResource(R.string.map_view))
if (tourList.size > 1) {
Text(
text = selectedTour,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
}
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
IconButton(onClick = { showProviderDialog = true }) {
Icon(
Icons.Filled.Layers,
contentDescription = stringResource(R.string.map_provider_menu)
)
}
IconButton(onClick = { showSearchDialog = true }) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.search_place_action)
)
}
if (searchPin != null) {
IconButton(onClick = { searchPin = null }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.search_place_close)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// FAB: Als Wegpunkt übernehmen (aktueller Standort)
AnimatedVisibility(
visible = showAddWaypointAction && currentLocation != null,
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
ExtendedFloatingActionButton(
onClick = {
editDialogPrefill = currentLocation
editDialogWaypoint = null
showEditDialog = true
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
icon = { Icon(Icons.Filled.AddLocation, contentDescription = null) },
text = { Text(stringResource(R.string.add_waypoint_from_map)) }
)
}
// FAB: Wegpunkt am letzten Track-Punkt
AnimatedVisibility(
visible = currentTrack.isNotEmpty(),
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
ExtendedFloatingActionButton(
onClick = {
val last = currentTrack.lastOrNull()
if (last != null) {
editDialogPrefill = Pair(last.latitude, last.longitude)
editDialogWaypoint = null
showEditDialog = true
}
},
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
icon = { Icon(Icons.Filled.Timeline, contentDescription = null) },
text = { Text(stringResource(R.string.map_track_waypoint_at_last)) }
)
}
// FAB: GPS-Track aufnehmen / stoppen
if (isRecording) {
ExtendedFloatingActionButton(
onClick = { viewModel.stopTrackRecording() },
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
icon = { Icon(Icons.Filled.Stop, contentDescription = null) },
text = { Text(stringResource(R.string.map_track_stop)) }
)
} else {
FloatingActionButton(
onClick = { viewModel.startTrackRecording() },
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) {
Icon(Icons.Filled.FiberManualRecord, contentDescription = stringResource(R.string.map_track_start))
}
}
// FAB: Mein Standort
FloatingActionButton(
onClick = {
scope.launch {
val hasFine = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val mv = mapView ?: return@launch
if (hasFine || hasCoarse) {
val priority = if (hasFine) Priority.PRIORITY_HIGH_ACCURACY
else Priority.PRIORITY_BALANCED_POWER_ACCURACY
val lastLoc = runCatching {
fusedLocationClient.lastLocation.await()
}.getOrNull()
if (lastLoc != null) {
val gp = GeoPoint(lastLoc.latitude, lastLoc.longitude)
mv.controller.animateTo(gp)
mv.controller.setZoom(16.0)
currentLocation = Pair(lastLoc.latitude, lastLoc.longitude)
showAddWaypointAction = true
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
}
val cts = CancellationTokenSource()
val freshLoc = runCatching {
fusedLocationClient.getCurrentLocation(priority, cts.token).await()
}.getOrNull()
if (freshLoc != null) {
val gp = GeoPoint(freshLoc.latitude, freshLoc.longitude)
mv.controller.animateTo(gp)
mv.controller.setZoom(16.0)
currentLocation = Pair(freshLoc.latitude, freshLoc.longitude)
showAddWaypointAction = true
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
}
} else {
if (waypoints.isNotEmpty()) {
val first = waypoints.first()
mv.controller.animateTo(GeoPoint(first.latitude, first.longitude))
mv.controller.setZoom(15.0)
}
}
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Filled.MyLocation,
contentDescription = stringResource(R.string.my_location),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// ─── OSM-Karte ───────────────────────────────────────────────────
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).apply {
setTileSource(selectedProvider.toTileSource())
setMultiTouchControls(true)
controller.setZoom(12.0)
val center = if (waypoints.isNotEmpty()) {
GeoPoint(waypoints.first().latitude, waypoints.first().longitude)
} else if (currentTrack.isNotEmpty()) {
GeoPoint(currentTrack.last().latitude, currentTrack.last().longitude)
} else {
GeoPoint(51.1657, 10.4515) // Deutschlandmitte
}
controller.setCenter(center)
// Kompass-Overlay
val compass = CompassOverlay(
ctx,
InternalCompassOrientationProvider(ctx),
this
)
compass.enableCompass()
overlays.add(compass)
// Long-Press → neuer Wegpunkt
val eventsReceiver = object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?) = false
override fun longPressHelper(p: GeoPoint?): Boolean {
if (p != null) {
editDialogPrefill = Pair(p.latitude, p.longitude)
editDialogWaypoint = null
showEditDialog = true
}
return true
}
}
overlays.add(MapEventsOverlay(eventsReceiver))
mapView = this
drawMapOverlays(
mv = this,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
},
update = { mv ->
mapView = mv
drawMapOverlays(
mv = mv,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
)
// ─── Koordinaten-Infoband (GPS-Position) ─────────────────────────
AnimatedVisibility(
visible = showAddWaypointAction && currentLocation != null,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp),
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
) {
currentLocation?.let { (lat, lng) ->
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f),
shadowElevation = 4.dp,
modifier = Modifier.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.MyLocation,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(
"%.7f, %.7f".format(Locale.US, lat, lng),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// ─── Track-Statistik-Panel ───────────────────────────────────────
// Bottom padding leaves a clear strip for the OSM attribution badge.
AnimatedVisibility(
visible = isRecording || currentTrack.isNotEmpty(),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 12.dp, bottom = 40.dp),
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = if (isRecording)
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.93f)
else
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.93f),
shadowElevation = 4.dp
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
// Aufnahme-Indikator
Row(verticalAlignment = Alignment.CenterVertically) {
if (isRecording) {
Icon(
Icons.Filled.FiberManualRecord,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.map_track_recording),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
} else {
Icon(
Icons.Filled.Timeline,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.map_track_saved),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(4.dp))
// Statistik
val distM = trackDistanceMeters(currentTrack)
val distStr = if (distM >= 1000)
"%.1f km".format(distM / 1000.0)
else
"${distM.toInt()} m"
Text(
stringResource(
R.string.map_track_stats,
currentTrack.size,
distStr
),
style = MaterialTheme.typography.bodySmall,
color = if (isRecording)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
if (isRecording) {
Text(
formatElapsed(elapsedSeconds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
// Track löschen (nur wenn nicht gerade aufgenommen)
if (!isRecording && currentTrack.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { viewModel.clearTrack() },
modifier = Modifier.height(28.dp)
) {
Text(
stringResource(R.string.map_track_clear),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
// Raised above the OSM attribution strip so the mandatory badge is never covered.
AnimatedVisibility(
visible = !isRecording && currentTrack.isEmpty(),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 40.dp),
enter = fadeIn(),
exit = fadeOut()
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f),
shadowElevation = 2.dp
) {
Text(
text = stringResource(R.string.map_editor_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp)
)
}
}
// ─── Layer-/3D-Overlay-FAB (v2.2.1) ──────────────────────────────
// Schwebt rechts oben über der Karte, öffnet das Material-3-Panel
// mit Layer-Switches und 3D-Vorbereitungs-Hinweis.
MapLayerFab(
state = layerState,
onStateChange = { layerState = it },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
)
// ─── OSM-Attribution (Pflicht) ───────────────────────────────────
// Moved to bottom-left so the FAB stack at bottom-right cannot overlap it.
// The track-stats card and editor hint above leave a clear strip for this badge.
OsmAttributionBadge(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 6.dp, bottom = 6.dp)
)
}
}
DisposableEffect(Unit) {
onDispose {
mapView?.onDetach()
}
}
}
// ---------------------------------------------------------------------------
// Overlays zeichnen (Wegpunkte + Track-Polyline)
// ---------------------------------------------------------------------------
private fun drawMapOverlays(
mv: MapView,
waypoints: List<Waypoint>,
trackPoints: List<GpsTrackPoint>,
primaryArgb: Int,
errorArgb: Int,
trackArgb: Int,
selectedWaypointId: String?,
onWaypointTapped: (Waypoint) -> Unit,
searchPin: GeoPoint? = null,
showRadius: Boolean = true
) {
val overlays = mv.overlays
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen.
// Mein-Standort-Puck überlebt (eigener Marker mit title="Mein Standort").
overlays.removeAll(overlays.filterIsInstance<Polygon>().toSet())
val waypointMarkers = overlays.filterIsInstance<Marker>()
.filter { it.title != "Mein Standort" }
overlays.removeAll(waypointMarkers.toSet())
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
val density = mv.resources.displayMetrics.density
// GPS-Track zeichnen — kräftiger, mit weißem Halo für Kontrast auf jedem Untergrund.
if (trackPoints.size >= 2) {
val pts = trackPoints.map { GeoPoint(it.latitude, it.longitude) }
// Halo (heller Rand)
val halo = Polyline(mv).apply {
setPoints(pts)
outlinePaint.color = 0xFFFFFFFF.toInt()
outlinePaint.strokeWidth = 18f * density / 2f
outlinePaint.strokeCap = Paint.Cap.ROUND
outlinePaint.strokeJoin = Paint.Join.ROUND
outlinePaint.alpha = 230
}
overlays.add(halo)
// Hauptlinie
val polyline = Polyline(mv).apply {
setPoints(pts)
outlinePaint.color = trackArgb
outlinePaint.strokeWidth = 12f * density / 2f
outlinePaint.strokeCap = Paint.Cap.ROUND
outlinePaint.strokeJoin = Paint.Join.ROUND
outlinePaint.alpha = 235
}
overlays.add(polyline)
}
// Wegpunkte zeichnen — größere, gut sichtbare Streckenpunkt-Handles
for (wp in waypoints) {
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
// Radius-Kreis (nur wenn Layer aktiv)
if (showRadius) {
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
overlays.add(circle)
}
val isSelected = selectedWaypointId != null && wp.id == selectedWaypointId
// Größerer Marker mit Kontrast-Halo. Ausgewählter Punkt: andere Farbe + größer.
val handleBitmap = createWaypointHandleBitmap(
context = mv.context,
fillArgb = if (isSelected) errorArgb else primaryArgb,
isSelected = isSelected
)
val marker = Marker(mv).apply {
position = geoPoint
title = wp.name
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
// ANCHOR_CENTER, ANCHOR_CENTER → Touch-Trefferfläche entspricht der Bitmap-Größe und
// sitzt zentriert über dem Punkt (deutlich größer als der frühere Default-Pin).
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
alpha = if (wp.isActive) 1.0f else 0.55f
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, handleBitmap)
// infoWindow = null → Tap löst onMarkerClick aus
setOnMarkerClickListener { _, _ ->
onWaypointTapped(wp)
true
}
}
overlays.add(marker)
}
// Temporärer roter Such-Treffer-Pin (Nominatim/OSM). Verändert keine Daten.
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()
}
/**
* Zeichnet einen großen, kontraststarken Kreis-Handle für einen Streckenpunkt.
* Die Bitmap-Größe bestimmt zugleich die Touch-Trefferfläche des osmdroid-Markers.
* - Normaler Punkt: ca. 44dp Trefferfläche, sichtbarer Kreis ~26dp mit weißem Halo
* - Ausgewählter Punkt: ca. 56dp Trefferfläche, sichtbarer Kreis ~36dp mit dunklem Außenrand
*/
private fun createWaypointHandleBitmap(
context: android.content.Context,
fillArgb: Int,
isSelected: Boolean
): Bitmap {
val density = context.resources.displayMetrics.density
// Touch-Trefferfläche (Bitmap-Kantenlänge) mind. 44dp/56dp lt. Material Touch-Targets
val touchDp = if (isSelected) 56f else 44f
val sizePx = (touchDp * density).toInt()
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val cx = sizePx / 2f
val cy = sizePx / 2f
// Sichtbarer Kreis-Radius
val visibleRadiusDp = if (isSelected) 18f else 13f
val visibleRadius = visibleRadiusDp * density
val haloThicknessDp = if (isSelected) 4.5f else 3.5f
val haloThickness = haloThicknessDp * density
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Weicher Schatten-/Halo-Ring (weiß), damit der Punkt auf jedem Kartenuntergrund sichtbar bleibt
paint.style = Paint.Style.FILL
paint.color = 0xFFFFFFFF.toInt()
canvas.drawCircle(cx, cy, visibleRadius + haloThickness, paint)
// Füllung
paint.color = fillArgb
canvas.drawCircle(cx, cy, visibleRadius, paint)
// Kontrast-Außenrand (dunkel) beim ausgewählten Punkt deutlich kräftiger
paint.style = Paint.Style.STROKE
paint.strokeWidth = (if (isSelected) 3f else 2f) * density
paint.color = 0xFF202020.toInt()
canvas.drawCircle(cx, cy, visibleRadius, paint)
if (isSelected) {
// Innerer heller Ring als zusätzliche „Selected"-Markierung
paint.style = Paint.Style.STROKE
paint.strokeWidth = 2f * density
paint.color = 0xFFFFFFFF.toInt()
canvas.drawCircle(cx, cy, visibleRadius - 4f * density, paint)
}
return bmp
}
// ---------------------------------------------------------------------------
// Hilfsfunktionen: Standort-Puck, Marker, Radius-Kreis, Distanz
// ---------------------------------------------------------------------------
private fun createLocationPuckBitmap(context: android.content.Context): Bitmap? {
return runCatching {
val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck)
?: return@runCatching null
val wrapped = DrawableCompat.wrap(drawable).mutate()
val size = (48 * context.resources.displayMetrics.density).toInt()
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
wrapped.setBounds(0, 0, canvas.width, canvas.height)
wrapped.draw(canvas)
bitmap
}.getOrNull()
}
private fun updateMyLocationMarker(
mapView: MapView,
position: GeoPoint,
existingMarker: Marker?,
puckBitmap: Bitmap?
): Marker {
if (existingMarker != null) {
mapView.overlays.remove(existingMarker)
}
val marker = Marker(mapView).apply {
this.position = position
title = "Mein Standort"
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
if (puckBitmap != null) {
icon = android.graphics.drawable.BitmapDrawable(
mapView.context.resources,
puckBitmap
)
}
infoWindow = null
}
mapView.overlays.add(marker)
mapView.invalidate()
return marker
}
private fun buildRadiusCircle(center: GeoPoint, radiusMeters: Double, colorArgb: Int): Polygon {
val points = mutableListOf<GeoPoint>()
val steps = 64
val earthRadius = 6_371_000.0
val lat0 = Math.toRadians(center.latitude)
val lon0 = Math.toRadians(center.longitude)
val d = radiusMeters / earthRadius
for (i in 0 until steps) {
val bearing = Math.toRadians(i * 360.0 / steps)
val lat1 = Math.asin(
sin(lat0) * cos(d) + cos(lat0) * sin(d) * cos(bearing)
)
val lon1 = lon0 + atan2(
sin(bearing) * sin(d) * cos(lat0),
cos(d) - sin(lat0) * sin(lat1)
)
points.add(GeoPoint(Math.toDegrees(lat1), Math.toDegrees(lon1)))
}
points.add(points.first())
return Polygon().apply {
this.points = points
val alpha30 = (colorArgb and 0x00FFFFFF) or 0x4D000000
fillColor = alpha30
strokeColor = colorArgb
strokeWidth = 2f
isVisible = true
}
}
/**
* Berechnet die Gesamtdistanz eines GPS-Tracks in Metern (Haversine).
*/
private fun trackDistanceMeters(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 p1 = points[i - 1]
val p2 = points[i]
val dLat = Math.toRadians(p2.latitude - p1.latitude)
val dLon = Math.toRadians(p2.longitude - p1.longitude)
val a = sin(dLat / 2).pow(2) +
cos(Math.toRadians(p1.latitude)) * cos(Math.toRadians(p2.latitude)) *
sin(dLon / 2).pow(2)
total += 2 * R * atan2(sqrt(a), sqrt(1 - a))
}
return total
}
/** Formatiert Sekunden als MM:SS oder HH:MM:SS. */
private fun formatElapsed(totalSeconds: Long): String {
val h = TimeUnit.SECONDS.toHours(totalSeconds)
val m = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
val s = totalSeconds % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s)
else "%02d:%02d".format(m, s)
}