Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,903 @@
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.MyLocation
import androidx.compose.material.icons.filled.PlayCircle
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.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.tileprovider.tilesource.TileSourceFactory
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
) {
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) }
// 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) }
// Bitmap für Standort-Puck (einmalig erstellt)
val puckBitmap = remember { createLocationPuckBitmap(context) }
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ────────
LaunchedEffect(waypoints, primaryColor, currentTrack) {
val mv = mapView ?: return@LaunchedEffect
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
// ─── 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
}
)
}
// ─── 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))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
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)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = 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(TileSourceFactory.MAPNIK)
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 = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
},
update = { mv ->
mapView = mv
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
)
// ─── 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 ───────────────────────────────────────
AnimatedVisibility(
visible = isRecording || currentTrack.isNotEmpty(),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 12.dp, bottom = 12.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) ──────────────────────────────
AnimatedVisibility(
visible = !isRecording && currentTrack.isEmpty(),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.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)
)
}
}
}
}
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,
onWaypointTapped: (Waypoint) -> Unit
) {
val overlays = mv.overlays
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen
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())
// GPS-Track zeichnen
if (trackPoints.size >= 2) {
val polyline = Polyline(mv).apply {
setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) })
outlinePaint.color = trackArgb
outlinePaint.strokeWidth = 6f
outlinePaint.alpha = 200
}
overlays.add(polyline)
}
// Wegpunkte zeichnen
for (wp in waypoints) {
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
// Radius-Kreis
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
overlays.add(circle)
// Marker mit Tap-Handler
val marker = Marker(mv).apply {
position = geoPoint
title = wp.name
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
alpha = if (wp.isActive) 1.0f else 0.4f
// infoWindow = null → Tap löst onMarkerClick aus
setOnMarkerClickListener { _, _ ->
onWaypointTapped(wp)
true
}
}
overlays.add(marker)
}
mv.invalidate()
}
// ---------------------------------------------------------------------------
// 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)
}