1133 lines
51 KiB
Kotlin
1133 lines
51 KiB
Kotlin
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)
|
||
}
|