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(null) } var myLocationMarker by remember { mutableStateOf(null) } var currentLocation by remember { mutableStateOf?>(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(null) } var editDialogPrefill by remember { mutableStateOf?>(null) } var showEditDialog by remember { mutableStateOf(false) } // Wegpunkt-Aktionsmenü (Marker antippen) var actionMenuWaypoint by remember { mutableStateOf(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(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(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, trackPoints: List, 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().toSet()) val waypointMarkers = overlays.filterIsInstance() .filter { it.title != "Mein Standort" } overlays.removeAll(waypointMarkers.toSet()) overlays.removeAll(overlays.filterIsInstance().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() 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): 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) }