Initial release of GPS2Audio
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user