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 android.os.Looper 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.clickable 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.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.AltRoute import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.GpsFixed import androidx.compose.material.icons.filled.GpsOff import androidx.compose.material.icons.filled.MovieFilter import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.OpenWith import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.NearMe import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.filled.TouchApp import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.surfaceColorAtElevation 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.material3.rememberModalBottomSheetState 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.mutableStateMapOf 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.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import de.waypointaudio.R import de.waypointaudio.data.ExplorerSettingsStore import de.waypointaudio.data.GpsTrackPoint import de.waypointaudio.data.MapBaseStyle import de.waypointaudio.data.MapStyleSettings import de.waypointaudio.data.MapStyleStore import de.waypointaudio.data.PoiCategory import de.waypointaudio.data.PoiLayerSettings import de.waypointaudio.data.TrackDraft import de.waypointaudio.data.Waypoint import de.waypointaudio.util.ExplorerPoiClient import de.waypointaudio.util.ExplorerPoiResult import de.waypointaudio.util.LatLngBbox import de.waypointaudio.util.WaypointAudioDuration import de.waypointaudio.viewmodel.WaypointViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import de.waypointaudio.util.PoiSubtype import org.maplibre.android.annotations.IconFactory import org.maplibre.android.annotations.Marker import org.maplibre.android.annotations.MarkerOptions import org.maplibre.android.annotations.Polygon import org.maplibre.android.annotations.PolygonOptions import org.maplibre.android.annotations.Polyline import org.maplibre.android.annotations.PolylineOptions import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style /** * v2.4.0 — MapLibre Engine Cutover. * * Aktiver Renderer im Explorer ist jetzt MapLibre Native (org.maplibre.gl:android-sdk:11.5.0). * Die osmdroid-basierte Variante in [ExplorerScreen] bleibt im Quelltext erhalten und * dient ausschließlich als interner Fallback — die Standard-Navigation (siehe * [MainActivity]) ruft nun [ExplorerScreenMapLibre] auf. * * Funktionsumfang (Parität zu v2.3.1): * - Wegpunkt-Marker (Auswahl/Aktionsdialog mit Clip-Playback) * - Wegpunkt setzen via FAB / Tap / LongPress * - Eigener Standort + Genauigkeitskreis + Follow-Schalter * - Trigger-Radius/Audiozone je Waypoint * - Audio-Längen-Ring/Badge (am Marker, layerable) * - GPS-Aufzeichnung als Polyline (live/Draft/persistiert) * - Routing-Polyline (OSRM-Endpunkt) + POI-MVP * - Storyboard-/Planlinie unverändert über BottomSheet * - Suche, Drafts-Sheet, persistente Follow-Einstellung * * Wesentliche Unterschiede zum osmdroid-Renderer: * - Karten-Tiles werden als raster source/layer programmatisch erzeugt (kein API-Key). * - Perspektive (3D-Vorschau) nutzt jetzt echte MapLibre Pitch/Tilt. * - Map-Listener für Follow-Disable verwenden onMoveStart/CameraIdle. * - Wegpunkt-/Track-Tap läuft über setOnMarkerClickListener. * * Clip-Längenlinien (v2.4.0 Korrektur): Diese werden nur noch dann gezeichnet, wenn * eine bekannte Geometrie vorliegt (aufgezeichneter Track, Draft, berechnete Route). * Ohne Geometrie wird ausschließlich Trigger-Radius/Audiodauer-Badge angezeigt * — keine frei projizierten Fantasielinien mehr. */ @SuppressLint("MissingPermission") @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExplorerScreenMapLibre( viewModel: WaypointViewModel, onNavigateBack: () -> Unit, onOpenInEditor: () -> Unit, onOpenDraft: (String) -> Unit = {}, currentMode: MapMode? = null, onModeChange: ((MapMode) -> Unit)? = null ) { val context = LocalContext.current 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 isRecording by viewModel.isRecording.collectAsState() val currentTrack by viewModel.currentTrack.collectAsState() val persistedTrack by viewModel.persistedTrack.collectAsState() val drafts by viewModel.trackDrafts.collectAsState() val lastSavedDraftId by viewModel.lastSavedDraftId.collectAsState() val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState() val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() // v2.4.6 — Aktive Tourroute (persistente Route der ausgewählten Tour). val activeTourRoute by viewModel.activeTourRoute.collectAsState() val waypoints = allWaypoints.filter { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour } val primaryColor = MaterialTheme.colorScheme.primary val primaryArgb = primaryColor.toArgb() val tertiaryArgb = MaterialTheme.colorScheme.tertiary.toArgb() val settingsStore = remember { ExplorerSettingsStore(context.applicationContext) } val persistedSettings by settingsStore.settings.collectAsState( initial = de.waypointaudio.data.ExplorerSettings() ) val mapStyleStore = remember { MapStyleStore(context.applicationContext) } val mapStyleSettings by mapStyleStore.settings.collectAsState( initial = MapStyleSettings() ) val scope = rememberCoroutineScope() // Audio-Dauer-Cache je Wegpunkt-URI. val audioDurationsMs = remember { mutableStateMapOf() } LaunchedEffect(waypoints.map { it.soundUri }) { for (wp in waypoints) { val uri = wp.soundUri if (uri.isBlank()) continue if (audioDurationsMs.containsKey(uri)) continue val d = withContext(Dispatchers.IO) { WaypointAudioDuration.resolveDurationMs(context, uri) } audioDurationsMs[uri] = d } } var showRoutingInfo by remember { mutableStateOf(false) } var showRoutePlanner by remember { mutableStateOf(false) } var routeLine by remember { mutableStateOf>(emptyList()) } var routeStatus by remember { mutableStateOf(null) } var routeLoading by remember { mutableStateOf(false) } var showRouteOverlay by remember { mutableStateOf(true) } var routePlan by remember { mutableStateOf(RoutePlan()) } // v2.4.6 — Distanz/Dauer der zuletzt berechneten Route (für persistente Speicherung). var lastComputedDistanceMeters by remember { mutableStateOf(null) } var lastComputedDurationSeconds by remember { mutableStateOf(null) } var lastComputedEndpoint by remember { mutableStateOf("") } // v2.5.35 — Phantom-GPX-Line-Fix: Im neutralen Standard-Kontext (keine laufende // Aufzeichnung, kein aktiver Draft) dürfen keine persistierten Linien angezeigt // werden. isNeutralStandard ist true, wenn die aktuelle Tour die Standardtour ist // UND kein eigener aktiver Kontext (Aufzeichnung / Draft) vorliegt. // isNeutralStandard wird auch für visibleTrack verwendet (s. u.). val isNeutralStandard: Boolean = selectedTour == de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME // v2.4.6 — Gespeicherte Tourroute als sekundäre Quelle für Clip-Linien // und als Visualisierung, wenn keine aktuelle Route/Planlinie aktiv ist. // v2.5.3 — Manuelle Routen werden separat (als manualRoutePolylines) // gezeichnet und über manualRoutePoints dargestellt. Hier liefern wir // sie deshalb NICHT als saved-route, damit sie nicht doppelt erscheinen. // v2.5.35 — Im neutralen Standard-Kontext (isNeutralStandard + keine Aufzeichnung // + kein Draft) wird savedRouteLine explizit leer gehalten, damit keine alte // persistierte TourRoute als Phantom-Linie erscheint. Daten werden NICHT gelöscht. val savedRouteLine: List = remember(activeTourRoute, isNeutralStandard, isRecording) { val r = activeTourRoute if (r == null || r.source == de.waypointaudio.data.RouteSourceKind.MANUAL) emptyList() // Phantom-Guard: Für die Standardtour keine gespeicherte Route zeigen, // solange keine Aufzeichnung läuft (aktiver Draft wird unten behandelt). else if (isNeutralStandard && !isRecording) emptyList() else r.polyline.map { LatLng(it.lat, it.lon) } } // Beim Wechsel der ausgewählten Tour: Plan und berechnete Route verwerfen, // damit der Routenplaner nicht eine fremde Route der Vortour zeigt. // // v2.5.2 — Wenn die neue Tour eine gespeicherte TourRoute hat, wird deren // Profil als Default in den frischen Plan übernommen. So bleibt das // Fortbewegungsmittel beim Neuberechnen konsistent zur gespeicherten Route. LaunchedEffect(selectedTour, activeTourRoute?.id) { val storedProfile = RoutingProfile.fromKey(activeTourRoute?.profile) routePlan = RoutePlan(profile = storedProfile) routeLine = emptyList() routeStatus = null lastComputedDistanceMeters = null lastComputedDurationSeconds = null lastComputedEndpoint = "" } // Planlinie wird automatisch aus dem Plan abgeleitet — Luftlinie zwischen // Start/Via/Ziel. Sie ersetzt die Route nur, wenn keine echte Route da ist // (kein konfigurierter Endpunkt oder Fehler). val routePlanline: List = remember(routePlan) { routePlan.orderedStops().map { LatLng(it.latitude, it.longitude) } .takeIf { it.size >= 2 } ?: emptyList() } val routingPoiCtx = LocalContext.current // MapLibre-spezifische State-Halter var mapView by remember { mutableStateOf(null) } var mapLibreMap by remember { mutableStateOf(null) } var styleReady by remember { mutableStateOf(false) } // Annotation-Caches: wir entfernen/erzeugen pro Update neu, halten aber die // Referenzen zu den eigenen Annotations, um sie gezielt löschen zu können. val waypointMarkers = remember { mutableListOf() } val waypointMarkerIdToWaypoint = remember { mutableMapOf() } val waypointRadiusPolygons = remember { mutableListOf() } val trackMarkers = remember { mutableListOf() } val trackMarkerIdToIndex = remember { mutableMapOf() } val trackPolylines = remember { mutableListOf() } val routePolylines = remember { mutableListOf() } val planlinePolylines = remember { mutableListOf() } val routeStopMarkers = remember { mutableListOf() } val audioLinePolylines = remember { mutableListOf() } // v2.5.3 — Annotations der manuell gezeichneten Route. val manualRoutePolylines = remember { mutableListOf() } val manualRouteMarkers = remember { mutableListOf() } val manualRouteMarkerIdToIndex = remember { mutableMapOf() } var myLocationMarker by remember { mutableStateOf(null) } var accuracyPolygon by remember { mutableStateOf(null) } var searchPinMarker by remember { mutableStateOf(null) } // v2.5.40 — Explorer-POI-State val explorerPoiMarkers = remember { mutableListOf() } val explorerPoiMarkerIdToResult = remember { mutableMapOf() } var explorerPoiResults by remember { mutableStateOf>>(emptyMap()) } var explorerPoiLoading by remember { mutableStateOf(false) } var explorerPoiSnackbar by remember { mutableStateOf(null) } var selectedExplorerPoi by remember { mutableStateOf(null) } // v2.5.42 — Legende standardmäßig eingeklappt var poiLegendExpanded by remember { mutableStateOf(false) } var currentMapZoom by remember { mutableStateOf(13.0) } val snackbarHostState = remember { SnackbarHostState() } // v2.5.45 — Auto-POI-Load State var autoPoiLoadEnabled by remember { mutableStateOf(true) } // standardmäßig aktiviert var poiAutoLoadJob by remember { mutableStateOf(null) } var lastAutoLoadLat by remember { mutableStateOf(null) } var lastAutoLoadLon by remember { mutableStateOf(null) } var lastAutoLoadZoom by remember { mutableStateOf(null) } var lastAutoLoadCats by remember { mutableStateOf?>(null) } var autoPoiStatus by remember { mutableStateOf(null) } // kurzer Status-Text var lastLat by remember { mutableStateOf(null) } var lastLng by remember { mutableStateOf(null) } var lastAccuracyM by remember { mutableStateOf(null) } var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED ) } var followLocation by remember { mutableStateOf(persistedSettings.followLocation) } var followInitialized by remember { mutableStateOf(false) } LaunchedEffect(persistedSettings.followLocation) { if (!followInitialized) { followLocation = persistedSettings.followLocation followInitialized = true } } var actionMenuWaypoint by remember { mutableStateOf(null) } var editDialogWaypoint by remember { mutableStateOf(null) } var editDialogPrefill by remember { mutableStateOf?>(null) } var showEditDialog by remember { mutableStateOf(false) } var deleteConfirm by remember { mutableStateOf(null) } var tapMode by remember { mutableStateOf(TapModeMl.None) } // v2.5.3 — Manueller Routemodus ("Route zeichnen"). // Die `manualRoutePoints`-Liste hält den aktuellen Wegverlauf rein lokal // (LatLng). Stille Punkte: Sie erscheinen NICHT in der Wegpunkt-/Track-Liste, // lösen KEIN Audio aus, beeinflussen KEINE Clip-/Audio-Funktionen. // Persistenz: bei jeder Änderung wird in [TourRoute] mit // [RouteSourceKind.MANUAL] gespeichert. Beim Tour-Wechsel laden wir die // gespeicherte manuelle Route falls vorhanden. var manualRouteMode by remember { mutableStateOf(false) } var manualRoutePoints by remember { mutableStateOf>(emptyList()) } var selectedRoutePointIndex by remember { mutableStateOf(null) } var showRoutePointDialog by remember { mutableStateOf(false) } // Beim Tour-Wechsel: bestehende manuelle Route laden. // v2.5.35 — Phantom-Guard: Für die Standardtour keine manuelle Route laden, // die aus einem früheren Zeichnen-Vorgang ohne explizite Tour-Auswahl stammt. LaunchedEffect(selectedTour, activeTourRoute?.id, activeTourRoute?.source) { val r = activeTourRoute manualRoutePoints = if ( r?.source == de.waypointaudio.data.RouteSourceKind.MANUAL && !isNeutralStandard // Phantom-Guard: Standardtour bleibt leer ) { r.polyline.map { LatLng(it.lat, it.lon) } } else emptyList() // Modus aufheben, damit man nicht versehentlich weiterzeichnet if (manualRouteMode) manualRouteMode = false if (tapMode is TapModeMl.DrawRoutePoint || tapMode is TapModeMl.MoveRoutePoint) { tapMode = TapModeMl.None } selectedRoutePointIndex = null showRoutePointDialog = false } // Auto-Save: jede Änderung der manuellen Route in TourRoute persistieren. // Beim Leeren wird die Tourroute entfernt (nur, wenn sie zuvor MANUAL war — // berechnete Routen bleiben unangetastet). LaunchedEffect(manualRoutePoints, selectedTour) { val currentSaved = activeTourRoute val wasManual = currentSaved?.source == de.waypointaudio.data.RouteSourceKind.MANUAL if (manualRoutePoints.isEmpty()) { if (wasManual) viewModel.deleteTourRouteForCurrent() return@LaunchedEffect } val polyline = manualRoutePoints.map { de.waypointaudio.data.TourRoutePoint(lat = it.latitude, lon = it.longitude) } val updated = de.waypointaudio.data.TourRoute( id = currentSaved?.id?.takeIf { wasManual } ?: java.util.UUID.randomUUID().toString(), polyline = polyline, source = de.waypointaudio.data.RouteSourceKind.MANUAL, endpoint = "Manuell gezeichnet", profile = currentSaved?.profile?.takeIf { wasManual } ?: "", createdAtMillis = currentSaved?.createdAtMillis?.takeIf { wasManual } ?: System.currentTimeMillis(), updatedAtMillis = System.currentTimeMillis(), ) viewModel.saveTourRouteForCurrent(updated) } val puckBitmap = remember { explorerMlLocationPuckBitmap(context) } var layerState by remember { mutableStateOf(MapLayerState(followLocation = persistedSettings.followLocation)) } var styleHydrated by remember { mutableStateOf(false) } LaunchedEffect(mapStyleSettings) { if (!styleHydrated) { // v2.5.38 — Beim ersten Laden alle persistierten Einstellungen übernehmen, // inkl. baseStyle (Kartentyp-Persistenz) und poiLayers. layerState = layerState.copy( baseStyle = mapStyleSettings.baseStyle, showAudioLength = mapStyleSettings.showAudioLength, showAudioLines = mapStyleSettings.showAudioLines, poiLayers = mapStyleSettings.poiLayers, ) styleHydrated = true } } LaunchedEffect(followLocation) { if (layerState.followLocation != followLocation) { layerState = layerState.copy(followLocation = followLocation) } runCatching { settingsStore.setFollowLocation(followLocation) } } LaunchedEffect(layerState.followLocation) { if (followLocation != layerState.followLocation) { followLocation = layerState.followLocation } } LaunchedEffect(layerState.baseStyle) { if (styleHydrated && layerState.baseStyle != mapStyleSettings.baseStyle) { runCatching { mapStyleStore.setBaseStyle(layerState.baseStyle) } } } LaunchedEffect(layerState.showAudioLength) { if (styleHydrated && layerState.showAudioLength != mapStyleSettings.showAudioLength) { runCatching { mapStyleStore.setShowAudioLength(layerState.showAudioLength) } } } LaunchedEffect(layerState.showAudioLines) { if (styleHydrated && layerState.showAudioLines != mapStyleSettings.showAudioLines) { runCatching { mapStyleStore.setShowAudioLines(layerState.showAudioLines) } } } // v2.5.38 — POI-Layer-Auswahl persistent speichern. // v2.5.45 — Bei Kategorie-Änderung: Cache leeren und Auto-Load-Cursor zurücksetzen. LaunchedEffect(layerState.poiLayers) { if (styleHydrated && layerState.poiLayers != mapStyleSettings.poiLayers) { runCatching { mapStyleStore.setPoiLayers(layerState.poiLayers) } } // Kategorie-Wechsel → nächster Camera-Idle lädt frisch lastAutoLoadCats = null de.waypointaudio.util.ExplorerPoiClient.clearCache() } // v2.5.38 — POI-Sichtbarkeit auf den geladenen MapLibre-Style anwenden. // Greift auf heuristische Layer-ID-Präfixe des OpenMapTiles-Schemas zurück. // Wenn ein Layer nicht existiert, passiert nichts (kein Crash). LaunchedEffect(layerState.poiLayers, layerState.baseStyle, styleReady) { if (!styleReady) return@LaunchedEffect val ml = mapLibreMap ?: return@LaunchedEffect val style = ml.style ?: return@LaunchedEffect // Klassisch (Raster): keine Vektor-Layer anwendbar. if (layerState.baseStyle == MapBaseStyle.CLASSIC || layerState.baseStyle == MapBaseStyle.SATELLITE) return@LaunchedEffect applyPoiLayerVisibility(style, layerState.poiLayers) } // v2.5.40 — Snackbar-Anzeige für Explorer-POI-Meldungen LaunchedEffect(explorerPoiSnackbar) { val msg = explorerPoiSnackbar ?: return@LaunchedEffect snackbarHostState.showSnackbar(msg) explorerPoiSnackbar = null } // v2.5.42 — Namen-Labels über Toggle (nicht mehr zoom-abhängig automatisch) val showPoiNames = layerState.poiLayers.showPoiNames // v2.5.42 — Explorer-POI-Marker zeichnen/aktualisieren. // Schlüssel inkl. showPoiNames: Marker werden bei Toggle-Änderung neu gezeichnet. // Fix: Kategorie-Bitmap wird per buildExplorerPoiIcon(cat, showPoiNames, name) mit // eindeutiger Kategorie-Kennung erzeugt — verhindert Icon-Cache-Kollisionen in MapLibre. // v2.5.46 — Atomare POI-Marker-Aktualisierung: erst neue Marker hinzufügen, // dann alte entfernen. So gibt es keine sichtbare Lücke (kein Flackern) beim // Kartennachladen. Außerdem: maximale Anzahl angezeigter Marker auf 280 begrenzt, // benannte und wichtige Subtypen werden priorisiert. LaunchedEffect(explorerPoiResults, styleReady, showPoiNames) { if (!styleReady) return@LaunchedEffect val ml = mapLibreMap ?: return@LaunchedEffect if (explorerPoiResults.isEmpty()) { // Nur leeren wenn wirklich leer (z.B. manueller Clear) explorerPoiMarkers.forEach { runCatching { ml.removeMarker(it) } } explorerPoiMarkers.clear() explorerPoiMarkerIdToResult.clear() return@LaunchedEffect } val iconFactory = IconFactory.getInstance(context) // v2.5.46 — POI-Dichte-Begrenzung: max. 280 Marker pro Viewport, // priorisiere benannte POIs und wichtige Subtypen. val MAX_VISIBLE_POIS = 280 val importantSubtypes = setOf( de.waypointaudio.util.PoiSubtype.CASTLE, de.waypointaudio.util.PoiSubtype.CHURCH, de.waypointaudio.util.PoiSubtype.MUSEUM, de.waypointaudio.util.PoiSubtype.VIEWPOINT, de.waypointaudio.util.PoiSubtype.MONUMENT, de.waypointaudio.util.PoiSubtype.TRAIN_STATION, de.waypointaudio.util.PoiSubtype.HOSPITAL, de.waypointaudio.util.PoiSubtype.PHARMACY, ) // Alle POIs flach aufsammeln, sortieren (benannte + wichtige zuerst) val allPois: List> = explorerPoiResults.flatMap { (cat, pois) -> pois.map { cat to it } } val totalCount = allPois.size val sortedPois = allPois.sortedWith( compareByDescending> { val poi = it.second // Priorität: benannter POI + wichtiger Subtype = höchste Priorität val isNamed = poi.displayName.isNotBlank() && poi.displayName != poi.subtype.displayLabel val isImportant = poi.subtype in importantSubtypes when { isNamed && isImportant -> 3 isNamed -> 2 isImportant -> 1 else -> 0 } } ) val visiblePois = if (totalCount > MAX_VISIBLE_POIS) sortedPois.take(MAX_VISIBLE_POIS) else sortedPois val shownCount = visiblePois.size // v2.5.46 — Status-Text (zeigt "N von M" wenn begrenzt) if (totalCount > MAX_VISIBLE_POIS) { autoPoiStatus = "POIs: $shownCount von $totalCount angezeigt" } // Neue Marker aufbauen (noch nicht auf der Karte) val newMarkers = mutableListOf() val newMarkerIdToResult = mutableMapOf() for ((cat, poi) in visiblePois) { // v2.5.45 — Subtype-Icon: Piktogramm variiert nach Untertyp, Farbe bleibt Kategorie val iconBitmap = if (showPoiNames) { buildExplorerPoiIconWithLabel(cat, poi.subtype, poi.displayName) } else { buildExplorerPoiIcon(cat, poi.subtype) } val icon = iconFactory.fromBitmap(iconBitmap) val mo = MarkerOptions() .position(LatLng(poi.latitude, poi.longitude)) .title(poi.displayName) .snippet(poi.subtype.displayLabel) // v2.5.45: Subtype-Label statt categoryLabel .icon(icon) val marker = runCatching { ml.addMarker(mo) }.getOrNull() ?: continue newMarkers.add(marker) newMarkerIdToResult[marker.id] = poi } // v2.5.46 — ERST nach Hinzufügen der neuen Marker die alten entfernen // → atomare Aktualisierung ohne sichtbare Lücke / Flackern explorerPoiMarkers.forEach { runCatching { ml.removeMarker(it) } } explorerPoiMarkers.clear() explorerPoiMarkerIdToResult.clear() explorerPoiMarkers.addAll(newMarkers) explorerPoiMarkerIdToResult.putAll(newMarkerIdToResult) } var showSearchDialog by remember { mutableStateOf(false) } var searchPin by remember { mutableStateOf(null) } var showDraftsSheet by remember { mutableStateOf(false) } var showStoryboardSheet by remember { mutableStateOf(false) } var showDraftPointMenu by remember { mutableStateOf(null) } var activeDraftId by remember { mutableStateOf(null) } val activeDraft = activeDraftId?.let { id -> drafts.firstOrNull { it.id == id } } var selectedTrackIndex by remember { mutableStateOf(null) } LaunchedEffect(lastSavedDraftId) { val id = lastSavedDraftId if (id != null) { activeDraftId = id showDraftsSheet = true viewModel.consumeLastSavedDraftId() } } // v2.5.36 — Phantom-Guard für persistedTrack (verschärft): // Im neutralen Standard-Kontext (Standardtour, keine Aufzeichnung, kein Draft) // darf KEIN Track angezeigt werden — weder persistedTrack noch currentTrack // aus dem TrackRecordingManager, der beim Tour-Wechsel mit _persistedTrack // befüllt wird (v2.5.33-Logik). Die isNeutralStandard-Prüfung muss deshalb // VOR der currentTrack.isNotEmpty()-Prüfung stehen, da der Manager-Track // sonst die Guard umgeht und die weiße Phantom-Linie weiterhin erscheint. val visibleTrack: List = when { isRecording -> currentTrack activeDraft != null -> activeDraft.points // Phantom-Guard (v2.5.36): Standardtour ohne aktive Aufzeichnung/Draft // → ALLE Tracks (inkl. currentTrack/Manager-Track) unterdrücken. // Wird bewusst vor currentTrack.isNotEmpty() geprüft, damit der // Manager-Track (aus loadPersistedTrackForCurrentTour) nicht durch // die currentTrack-Prüfung sichtbar wird. isNeutralStandard -> emptyList() currentTrack.isNotEmpty() -> currentTrack else -> persistedTrack } val visibleMarkedIndexes: List = activeDraft?.markedIndexes ?: emptyList() // v2.5.37 — Style nachziehen, wenn Basis-Stil wechselt. // OpenFreeMap Vektor-Stile werden über Style.Builder().fromUri() geladen. // CLASSIC nutzt weiterhin den raster-JSON-Weg. // Phantom-Layer-Cleanup vor setStyle: alle Annotations-Caches leeren, // damit nach styleReady=true ein kompletter Rehydration-Durchlauf startet. LaunchedEffect( layerState.baseStyle, mapStyleSettings.perspectiveTilt, styleReady ) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect // Phantom-Layer-Cleanup vor Stilwechsel. styleReady = false clearAllAnnotationsCaches( waypointMarkers, waypointMarkerIdToWaypoint, waypointRadiusPolygons, trackMarkers, trackMarkerIdToIndex, trackPolylines, routePolylines, audioLinePolylines, manualRoutePolylines, manualRouteMarkers, manualRouteMarkerIdToIndex ) myLocationMarker = null accuracyPolygon = null searchPinMarker = null val pitchDeg = if (layerState.baseStyle == MapBaseStyle.CITY_3D || layerState.baseStyle == MapBaseStyle.TERRAIN_3D || layerState.baseStyle == MapBaseStyle.PERSPECTIVE) { mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0) } else 0.0 val onStyleReady: (Style) -> Unit = { loadedStyle -> // v2.5.37 — 3D Stadt: Gebäude-Extrusion hinzufügen falls building-Layer vorhanden. // Robust: wenn Source/Layer fehlt, kein Crash — einfach 2D-Fallback. if (layerState.baseStyle == MapBaseStyle.CITY_3D) { try { addBuildingExtrusionIfAvailable(loadedStyle) } catch (_: Exception) { /* 2D-Fallback: Layer existiert nicht */ } } // v2.5.38 — POI-Layer-Sichtbarkeit nach Style-Load anwenden. if (layerState.baseStyle != MapBaseStyle.CLASSIC && layerState.baseStyle != MapBaseStyle.SATELLITE) { try { applyPoiLayerVisibility(loadedStyle, layerState.poiLayers) } catch (_: Exception) { /* Kein Crash wenn Layer nicht vorhanden */ } } // Kamera-Pitch setzen. val pos = ml.cameraPosition ml.cameraPosition = CameraPosition.Builder(pos).tilt(pitchDeg).build() styleReady = true } when (layerState.baseStyle) { MapBaseStyle.MODERN_2D -> { ml.setStyle( Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron"), onStyleReady ) } MapBaseStyle.DARK -> { ml.setStyle( Style.Builder().fromUri("https://tiles.openfreemap.org/styles/dark"), onStyleReady ) } MapBaseStyle.OUTDOOR, MapBaseStyle.TOPO -> { ml.setStyle( Style.Builder().fromUri("https://tiles.openfreemap.org/styles/liberty"), onStyleReady ) } MapBaseStyle.CITY_3D, MapBaseStyle.TERRAIN_3D, MapBaseStyle.PERSPECTIVE -> { // Positron als Basis für 3D-Stadt — nach Laden Gebäude-Extrusion einfügen. ml.setStyle( Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron"), onStyleReady ) } MapBaseStyle.CLASSIC, MapBaseStyle.SATELLITE -> { // Klassisch: OSM-Raster-Fallback (JSON-basiert, kein Vektor). val classicJson = buildClassicOsmStyleJson() ml.setStyle(Style.Builder().fromJson(classicJson), onStyleReady) } } } // Berechnete Routenlinie + Planlinie + Stop-Marker als eigene Polyline-Schicht. LaunchedEffect(routeLine, routePlanline, routePlan, showRouteOverlay, styleReady, savedRouteLine) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect for (pl in routePolylines) runCatching { ml.removePolyline(pl) } routePolylines.clear() for (pl in planlinePolylines) runCatching { ml.removePolyline(pl) } planlinePolylines.clear() for (m in routeStopMarkers) runCatching { ml.removeMarker(m) } routeStopMarkers.clear() if (!showRouteOverlay) return@LaunchedEffect // v2.4.5 — Planlinie (Luftlinie zwischen Stops) wird NUR gezeichnet, // wenn keine berechnete Route vorhanden ist. Sobald eine echte // Routing-Polyline existiert, ist die Planlinie redundant und // optisch störend (parallel-laufende dünne Linie auf der Route). val hasComputedRoute = routeLine.size >= 2 if (!hasComputedRoute && routePlanline.size >= 2) { val halo = ml.addPolyline( PolylineOptions() .addAll(routePlanline) .color(0xFFFFFFFF.toInt()) .width(7f) .alpha(0.7f) ) val line = ml.addPolyline( PolylineOptions() .addAll(routePlanline) .color(0xFFFF9800.toInt()) .width(4f) .alpha(0.55f) ) planlinePolylines += halo planlinePolylines += line } // v2.4.6 — Gespeicherte Tourroute zeichnen, wenn keine aktuell berechnete // Route vorhanden ist. Sobald `routeLine` aktiv ist, übernimmt diese, // damit Live-Berechnung > gespeicherte Tourroute (sichtbare Priorität // entspricht der Clip-Linien-Priorität). if (routeLine.size < 2 && savedRouteLine.size >= 2) { val halo = ml.addPolyline( PolylineOptions() .addAll(savedRouteLine) .color(0xFFFFFFFF.toInt()) .width(8f) .alpha(0.75f) ) val line = ml.addPolyline( PolylineOptions() .addAll(savedRouteLine) // Sichtbar unterscheidbar: tieferes Grün für „dauerhaft gespeichert". .color(0xFF2E7D32.toInt()) .width(5f) .alpha(0.9f) ) routePolylines += halo routePolylines += line } if (routeLine.size >= 2) { val halo = ml.addPolyline( PolylineOptions() .addAll(routeLine) .color(0xFFFFFFFF.toInt()) .width(9f) .alpha(0.85f) ) val line = ml.addPolyline( PolylineOptions() .addAll(routeLine) .color(0xFF1976D2.toInt()) .width(6f) .alpha(0.95f) ) routePolylines += halo routePolylines += line } val iconFactory = IconFactory.getInstance(context) // Stop-Marker für Start/Via/Ziel routePlan.start?.let { s -> val bm = routeStopBitmap(RouteStopKind.START, context) routeStopMarkers += ml.addMarker( MarkerOptions() .position(LatLng(s.latitude, s.longitude)) .title("Start: ${s.label}") .icon(iconFactory.fromBitmap(bm)) ) } routePlan.via.forEachIndexed { idx, v -> val bm = routeStopBitmap(RouteStopKind.VIA, context, label = (idx + 1).toString()) routeStopMarkers += ml.addMarker( MarkerOptions() .position(LatLng(v.latitude, v.longitude)) .title("Via ${idx + 1}: ${v.label}") .icon(iconFactory.fromBitmap(bm)) ) } routePlan.destination?.let { d -> val bm = routeStopBitmap(RouteStopKind.DESTINATION, context) routeStopMarkers += ml.addMarker( MarkerOptions() .position(LatLng(d.latitude, d.longitude)) .title("Ziel: ${d.label}") .icon(iconFactory.fromBitmap(bm)) ) } } // v2.5.3 — Manuelle Route als eigene Schicht zeichnen. // Stille Routenpunkte (kleine, dunkle Kreise) + verbindende Linie. // Diese Punkte sind KEINE Wegpunkte und werden niemals in der // Wegpunktliste, Track-Liste oder Storyboard erscheinen. LaunchedEffect(manualRoutePoints, manualRouteMode, styleReady, selectedRoutePointIndex, primaryArgb) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect for (pl in manualRoutePolylines) runCatching { ml.removePolyline(pl) } manualRoutePolylines.clear() for (m in manualRouteMarkers) runCatching { ml.removeMarker(m) } manualRouteMarkers.clear() manualRouteMarkerIdToIndex.clear() if (manualRoutePoints.size >= 2) { val halo = ml.addPolyline( PolylineOptions() .addAll(manualRoutePoints) .color(0xFFFFFFFF.toInt()) .width(8f) .alpha(0.75f) ) val line = ml.addPolyline( PolylineOptions() .addAll(manualRoutePoints) // Klar unterscheidbar von OSRM-Routen (blau) und gespeicherten // Tourrouten (grün): violett für "manuell gezeichnet". .color(0xFF6A1B9A.toInt()) .width(5f) .alpha(0.95f) ) manualRoutePolylines += halo manualRoutePolylines += line } // Punkt-Marker pro Routenpunkt – klein, ohne Audio-/Wegpunkt-Bezug. if (manualRoutePoints.isNotEmpty()) { val iconFactory = IconFactory.getInstance(context) val sizePx = (context.resources.displayMetrics.density * 14).toInt() manualRoutePoints.forEachIndexed { idx, p -> val selected = selectedRoutePointIndex == idx val bm = trackPointBitmap(sizePx, 0xFF6A1B9A.toInt(), selected) val marker = ml.addMarker( MarkerOptions() .position(p) .title("Routenpunkt ${idx + 1}") .icon(iconFactory.fromBitmap(bm)) ) manualRouteMarkers += marker manualRouteMarkerIdToIndex[marker.id] = idx } } } // Haupt-Overlay-Refresh: Wegpunkte, Tracks, Audiozonen, Audio-Längen-Linien, // sowie der Search-Pin. LaunchedEffect( waypoints, primaryArgb, actionMenuWaypoint?.id, visibleTrack, visibleMarkedIndexes, selectedTrackIndex, searchPin, layerState, audioDurationsMs.toMap(), routeLine, routePlanline, savedRouteLine, manualRoutePoints, styleReady ) { val ml = mapLibreMap ?: return@LaunchedEffect if (!styleReady) return@LaunchedEffect drawExplorerOverlaysMapLibre( ml = ml, context = context, waypoints = if (layerState.showMarkers) waypoints else emptyList(), track = if (layerState.showTrack) visibleTrack else emptyList(), markedIndexes = visibleMarkedIndexes, selectedTrackIndex = selectedTrackIndex, primaryArgb = primaryArgb, trackArgb = tertiaryArgb, showRadius = layerState.showRadius, showAudioLength = layerState.showAudioLength, showAudioLines = layerState.showAudioLines, audioDurationsMs = audioDurationsMs.toMap(), selectedId = actionMenuWaypoint?.id, searchPin = searchPin, // v2.4.6: Geometrie für Clip-Längenlinien. // Reihenfolge der Priorität (laut Architektur-Abstimmung): // 1) Aktuell berechnete Routenlinie (routeLine), wenn vorhanden. // 2) Gespeicherte Tourroute (savedRouteLine), wenn vorhanden. // 3) Sichtbarer Track/Draft (visibleTrack), wenn mind. 2 Punkte. // 4) Explizite Planlinie (routePlanline) als letzte Notlösung. // 5) sonst NULL → keine Clip-Linien (nur Radius/Audio-Badge). // Damit dominiert eine frisch berechnete Route, dann die fest an // der Tour gespeicherte Route, dann der reale Track, und nur ganz // zum Schluss eine Planlinie (Luftlinie). Keine freien Linien. clipLineGeometry = when { routeLine.size >= 2 -> routeLine savedRouteLine.size >= 2 -> savedRouteLine // v2.5.3 — Manuell gezeichnete Route gilt als Tourverlauf für // Clip-Längen entlang bekannter Geometrie. Audio-Waypoints // werden weiterhin separat auf Basis ihres soundUri/Dauer // gerendert — stille Routenpunkte erzeugen keine Clip-Linien. manualRoutePoints.size >= 2 -> manualRoutePoints visibleTrack.size >= 2 -> visibleTrack.map { LatLng(it.latitude, it.longitude) } routePlanline.size >= 2 -> routePlanline else -> null }, waypointMarkers = waypointMarkers, waypointMarkerIdToWaypoint = waypointMarkerIdToWaypoint, waypointRadiusPolygons = waypointRadiusPolygons, trackMarkers = trackMarkers, trackMarkerIdToIndex = trackMarkerIdToIndex, trackPolylines = trackPolylines, audioLinePolylines = audioLinePolylines, searchPinMarkerRef = { it -> searchPinMarker = it }, currentSearchPinMarker = searchPinMarker ) } // Standort-Updates: My-Marker, Genauigkeitskreis, ggf. Follow. DisposableEffect(hasPermission) { if (!hasPermission) return@DisposableEffect onDispose { } val client = LocationServices.getFusedLocationProviderClient(context) val req = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000L) .setMinUpdateIntervalMillis(1_000L) .build() val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { val loc = result.lastLocation ?: return lastLat = loc.latitude lastLng = loc.longitude lastAccuracyM = if (loc.hasAccuracy()) loc.accuracy else null val ml = mapLibreMap if (ml != null && styleReady) { val gp = LatLng(loc.latitude, loc.longitude) myLocationMarker?.let { runCatching { ml.removeMarker(it) } } myLocationMarker = ml.addMarker( MarkerOptions() .position(gp) .title("Mein Standort") .icon( puckBitmap?.let { IconFactory.getInstance(context).fromBitmap(it) } ?: IconFactory.getInstance(context).defaultMarker() ) ) accuracyPolygon?.let { runCatching { ml.removePolygon(it) } } val radius = lastAccuracyM?.toDouble() ?: 0.0 accuracyPolygon = if (radius > 0.0) { ml.addPolygon( PolygonOptions() .addAll(circleLatLng(loc.latitude, loc.longitude, radius, 60)) .fillColor((primaryArgb and 0x00FFFFFF) or 0x33000000) .strokeColor(primaryArgb) .alpha(0.4f) ) } else null if (followLocation) { val current = ml.cameraPosition ml.animateCamera( CameraUpdateFactory.newCameraPosition( CameraPosition.Builder(current) .target(gp) .build() ) ) } } } } runCatching { client.requestLocationUpdates(req, callback, Looper.getMainLooper()) } onDispose { runCatching { client.removeLocationUpdates(callback) } } } // ─── Dialogs ───────────────────────────────────────────────────────────── 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( java.util.Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt() ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) ) val wpDuration = audioDurationsMs[wp.soundUri] ?: 0L if (wpDuration > 0L) { Text( stringResource( R.string.explorer_waypoint_audio_duration, WaypointAudioDuration.formatShort(wpDuration) ), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary ) } Button( onClick = { editDialogWaypoint = wp editDialogPrefill = null showEditDialog = true actionMenuWaypoint = null }, modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_action_edit)) } Button( onClick = { tapMode = TapModeMl.MoveWaypoint(wp.id) actionMenuWaypoint = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_action_move_tap)) } val hasClip = wp.soundUri.isNotBlank() val isThisPlaying = hasClip && singlePlayingWaypointId == wp.id && singlePlayingActive Button( onClick = { if (hasClip) viewModel.manualPlaySingle(wp) }, enabled = hasClip, colors = ButtonDefaults.buttonColors( containerColor = if (isThisPlaying) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiaryContainer, contentColor = if (isThisPlaying) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onTertiaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon( if (isThisPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp) ) Spacer(Modifier.width(6.dp)) Text( if (!hasClip) stringResource(R.string.explorer_action_no_clip) else if (isThisPlaying) stringResource(R.string.explorer_action_pause_clip) else stringResource(R.string.explorer_action_play_clip) ) } // v2.4.2 — Wegpunkt direkt in den Routenplaner übernehmen. Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { TextButton( onClick = { routePlan = routePlan.copy( start = RouteStop( latitude = wp.latitude, longitude = wp.longitude, label = wp.name.ifBlank { "Wegpunkt" }, source = RouteStopSource.WAYPOINT ) ) actionMenuWaypoint = null showRoutePlanner = true }, modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.route_planner_set_as_start)) } TextButton( onClick = { routePlan = routePlan.copy( via = routePlan.via + RouteStop( latitude = wp.latitude, longitude = wp.longitude, label = wp.name.ifBlank { "Wegpunkt" }, source = RouteStopSource.WAYPOINT ) ) actionMenuWaypoint = null showRoutePlanner = true }, modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.route_planner_set_as_via)) } TextButton( onClick = { routePlan = routePlan.copy( destination = RouteStop( latitude = wp.latitude, longitude = wp.longitude, label = wp.name.ifBlank { "Wegpunkt" }, source = RouteStopSource.WAYPOINT ) ) actionMenuWaypoint = null showRoutePlanner = true }, modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.route_planner_set_as_destination)) } } Button( onClick = { deleteConfirm = wp actionMenuWaypoint = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.delete)) } } }, confirmButton = { TextButton(onClick = { actionMenuWaypoint = null }) { Text(stringResource(R.string.cancel)) } } ) } // v2.5.3 — Einfacher Punkt-Dialog für Routenpunkte (Verschieben/Löschen). if (showRoutePointDialog) { val idx = selectedRoutePointIndex val pt = idx?.let { manualRoutePoints.getOrNull(it) } if (idx == null || pt == null) { showRoutePointDialog = false } else { AlertDialog( onDismissRequest = { showRoutePointDialog = false selectedRoutePointIndex = null }, title = { Text(stringResource(R.string.explorer_route_point_dialog_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( "%.6f, %.6f".format(java.util.Locale.US, pt.latitude, pt.longitude), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) ) Button( onClick = { tapMode = TapModeMl.MoveRoutePoint(idx) showRoutePointDialog = false }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_route_point_move)) } Button( onClick = { val ml = mapLibreMap val center = ml?.cameraPosition?.target if (center != null) { val pts = manualRoutePoints.toMutableList() if (idx in pts.indices) { pts[idx] = LatLng(center.latitude, center.longitude) manualRoutePoints = pts } } showRoutePointDialog = false selectedRoutePointIndex = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_action_move_to_center)) } Button( onClick = { val pts = manualRoutePoints.toMutableList() if (idx in pts.indices) { pts.removeAt(idx) manualRoutePoints = pts } showRoutePointDialog = false selectedRoutePointIndex = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_route_point_delete)) } } }, confirmButton = { TextButton(onClick = { showRoutePointDialog = false selectedRoutePointIndex = null }) { Text(stringResource(R.string.close)) } } ) } } deleteConfirm?.let { wp -> AlertDialog( onDismissRequest = { deleteConfirm = null }, title = { Text(stringResource(R.string.confirm_delete_waypoint_title)) }, text = { Text( stringResource( R.string.confirm_delete_waypoint_msg, wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) } ) ) }, confirmButton = { Button( onClick = { viewModel.delete(wp.id) deleteConfirm = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ) ) { Text(stringResource(R.string.delete)) } }, dismissButton = { TextButton(onClick = { deleteConfirm = null }) { Text(stringResource(R.string.cancel)) } } ) } 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 }, onDismiss = { showEditDialog = false editDialogWaypoint = null editDialogPrefill = null }, // v2.5.16 — Kopieren auch aus MapLibre-Bearbeiten-Flow möglich. onCopyToOtherTour = editDialogWaypoint?.let { wp -> { targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) } } ) } if (showRoutePlanner) { val locationLatLon: Pair? = lastLat?.let { lat -> lastLng?.let { lon -> lat to lon } } val mapCenterLatLon: Pair? = mapLibreMap?.cameraPosition?.target?.let { it.latitude to it.longitude } RoutePlannerSheet( plan = routePlan, routeStatus = routeStatus, hasRoute = routeLine.size >= 2, hasPlanline = routePlanline.size >= 2, isLoading = routeLoading, routingEndpoint = mapStyleSettings.routingEndpoint, showRouteOverlay = showRouteOverlay, onShowRouteOverlayChange = { showRouteOverlay = it }, onPlanChange = { newPlan -> routePlan = newPlan // Bei Plan-Änderung verwerfen wir die alte echte Route — sonst // bliebe die alte Linie sichtbar, obwohl die Stops sich geändert haben. routeLine = emptyList() if (routeStatus != null) routeStatus = null }, onComputeRoute = { // v2.4.4: Wenn kein Endpunkt gesetzt ist, geben wir keinen // grauen Button mehr aus, sondern lassen den Nutzer auf // 'Route berechnen' klicken und antworten mit einem klaren // Status, der direkt auf den Demo-Quick-Setup verweist // (siehe route_planner_status_endpoint_missing). val endpoint = de.waypointaudio.util.OsrmRoutingClient .normalizeBase(mapStyleSettings.routingEndpoint) // v2.5.2 — gewähltes Profil für API-Aufruf, Status und Persistenz. val profile = routePlan.profile val profileLabel = routingPoiCtx.getString(profileLabelRes(profile)) if (endpoint.isBlank()) { routeStatus = routingPoiCtx.getString( R.string.route_planner_status_endpoint_missing_with_profile, profileLabel ) return@RoutePlannerSheet } val pts = routePlan.toOsrmPoints() if (pts.size < 2) { routeStatus = routingPoiCtx.getString(R.string.routing_compute_no_points) return@RoutePlannerSheet } routeLoading = true routeStatus = routingPoiCtx.getString(R.string.routing_compute_loading) scope.launch { runCatching { de.waypointaudio.util.OsrmRoutingClient.route( routingPoiCtx, endpoint, pts, profile.osrmKey ) }.onSuccess { result -> routeLine = result.polyline.map { (lat, lon) -> LatLng(lat, lon) } // v2.4.6 — Metadaten für die persistente Tourroute merken. lastComputedDistanceMeters = result.distanceMeters lastComputedDurationSeconds = result.durationSeconds lastComputedEndpoint = endpoint routeStatus = routingPoiCtx.getString( R.string.routing_compute_success, de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) ) }.onFailure { ex -> routeLine = emptyList() lastComputedDistanceMeters = null lastComputedDurationSeconds = null lastComputedEndpoint = "" // v2.5.2: Fehlermeldung enthält das gewählte Profil, // damit der Nutzer einordnen kann, ob z. B. das // OSRM-Backend ein bestimmtes Profil nicht anbietet // (typische Fehlerquelle bei eigenen Servern). val detail = (ex.message ?: ex::class.java.simpleName).take(80) routeStatus = routingPoiCtx.getString( R.string.route_planner_status_error_with_profile, profileLabel, detail ) } routeLoading = false } }, onUseDemoEndpoint = { // v2.4.4: Quick-Setup für den OSRM-Demo-Server. Setzt einmalig // den Endpunkt — der Nutzer kann ihn jederzeit unter „Endpunkt // einrichten" durch einen eigenen self-hosted Server ersetzen. scope.launch { runCatching { mapStyleStore.setRoutingEndpoint( de.waypointaudio.util.OsrmRoutingClient.DEMO_ENDPOINT ) } routeStatus = routingPoiCtx.getString(R.string.route_planner_demo_endpoint_set) } }, onShowPlanline = { routeLine = emptyList() if (routePlanline.size >= 2) { routeStatus = routingPoiCtx.getString(R.string.route_planner_planline_active) val ml = mapLibreMap if (ml != null) { val mid = routePlanline[routePlanline.size / 2] ml.animateCamera(CameraUpdateFactory.newLatLngZoom(mid, 13.0)) } } }, onClear = { routePlan = RoutePlan() routeLine = emptyList() routeStatus = null }, onConfigureEndpoint = { showRoutePlanner = false showRoutingInfo = true }, onSaveAsTourDraft = { tourName -> val orderedStops = routePlan.orderedStops() if (orderedStops.isEmpty()) return@RoutePlannerSheet // Wegpunkte aus den Stops anlegen und in neue Tour ablegen. val target = tourName.ifBlank { "Routenentwurf" } val newWaypoints = orderedStops.mapIndexed { i, stop -> Waypoint( name = "${stop.label.take(40)} #${i + 1}", latitude = stop.latitude, longitude = stop.longitude, radiusMeters = 40f, tourName = target, isActive = true ) } for (wp in newWaypoints) viewModel.upsert(wp) // v2.4.6 — Wenn aus dem Storyboard/Tour-Entwurf eine Tour mit // Geometrie entsteht, optional auch eine Tourroute speichern. // Verwendet die aktuell sichtbare Geometrie (Route oder // Planlinie). Keine automatische Audioerzeugung. val tourRoute = buildTourRouteFromState( plan = routePlan, routeLine = routeLine, routePlanline = routePlanline, distanceMeters = lastComputedDistanceMeters, durationSeconds = lastComputedDurationSeconds, endpoint = lastComputedEndpoint, planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) ) if (tourRoute != null) { viewModel.saveTourRouteFor(target, tourRoute) } routeStatus = routingPoiCtx.getString( R.string.route_planner_save_as_tour_done, target ) }, onAddStopsAsWaypoints = { val orderedStops = routePlan.orderedStops() if (orderedStops.isEmpty()) return@RoutePlannerSheet val target = selectedTour var count = 0 for ((i, stop) in orderedStops.withIndex()) { viewModel.upsert( Waypoint( name = "${stop.label.take(40)} #${i + 1}", latitude = stop.latitude, longitude = stop.longitude, radiusMeters = 40f, tourName = target, isActive = true ) ) count++ } routeStatus = routingPoiCtx.getString(R.string.route_planner_add_stops_done, count) }, // v2.4.6 — Tourrouten-Aktionen currentTourName = selectedTour, savedTourRoute = activeTourRoute, onSaveTourRoute = { val route = buildTourRouteFromState( plan = routePlan, routeLine = routeLine, routePlanline = routePlanline, distanceMeters = lastComputedDistanceMeters, durationSeconds = lastComputedDurationSeconds, endpoint = lastComputedEndpoint, planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) ) if (route != null) { viewModel.saveTourRouteForCurrent(route) routeStatus = routingPoiCtx.getString(R.string.tour_route_saved_msg, selectedTour) } }, onReplaceTourRoute = { val route = buildTourRouteFromState( plan = routePlan, routeLine = routeLine, routePlanline = routePlanline, distanceMeters = lastComputedDistanceMeters, durationSeconds = lastComputedDurationSeconds, endpoint = lastComputedEndpoint, planlineSourceLabel = routingPoiCtx.getString(R.string.tour_route_source_planline) ) if (route != null) { viewModel.saveTourRouteForCurrent(route) routeStatus = routingPoiCtx.getString(R.string.tour_route_replaced_msg, selectedTour) } }, onDeleteTourRoute = { viewModel.deleteTourRouteForCurrent() routeStatus = routingPoiCtx.getString(R.string.tour_route_deleted_msg, selectedTour) }, currentLocationLatLon = locationLatLon, mapCenterLatLon = mapCenterLatLon, availableWaypoints = waypoints, onDismiss = { showRoutePlanner = false } ) } if (showRoutingInfo) { RoutingPoiSheet( centerLat = lastLat ?: mapLibreMap?.cameraPosition?.target?.latitude, centerLon = lastLng ?: mapLibreMap?.cameraPosition?.target?.longitude, routingEndpoint = mapStyleSettings.routingEndpoint, onRoutingEndpointChange = { url -> scope.launch { runCatching { mapStyleStore.setRoutingEndpoint(url) } } }, onAddPoiAsWaypoint = { poi -> val newWp = Waypoint( id = java.util.UUID.randomUUID().toString(), name = poi.displayName.take(60), latitude = poi.latitude, longitude = poi.longitude, radiusMeters = 30f, soundUri = "", tourName = selectedTour, isActive = true ) viewModel.upsert(newWp) }, onAddPoiToStoryboard = { poi -> val newWp = Waypoint( id = java.util.UUID.randomUUID().toString(), name = poi.displayName.take(60), latitude = poi.latitude, longitude = poi.longitude, radiusMeters = 30f, soundUri = "", tourName = selectedTour, isActive = true ) viewModel.upsert(newWp) showStoryboardSheet = true }, onComputeRoute = { val endpoint = mapStyleSettings.routingEndpoint.trim() if (endpoint.isBlank()) { routeStatus = routingPoiCtx.getString(R.string.routing_endpoint_not_configured) return@RoutingPoiSheet } val pts = waypoints.map { it.latitude to it.longitude } if (pts.size < 2) { routeStatus = routingPoiCtx.getString(R.string.routing_compute_no_points) return@RoutingPoiSheet } routeLoading = true routeStatus = routingPoiCtx.getString(R.string.routing_compute_loading) scope.launch { runCatching { de.waypointaudio.util.OsrmRoutingClient.route(routingPoiCtx, endpoint, pts) }.onSuccess { result -> routeLine = result.polyline.map { (lat, lon) -> LatLng(lat, lon) } routeStatus = routingPoiCtx.getString( R.string.routing_compute_success, de.waypointaudio.util.OsrmRoutingClient.formatDistance(result.distanceMeters), de.waypointaudio.util.OsrmRoutingClient.formatDuration(result.durationSeconds) ) }.onFailure { routeLine = emptyList() routeStatus = routingPoiCtx.getString(R.string.routing_compute_error) } routeLoading = false } }, routeStatus = routeStatus, onDismiss = { showRoutingInfo = false } ) } if (showSearchDialog) { PlaceSearchDialog( onDismiss = { showSearchDialog = false }, onResultSelected = { result -> val gp = LatLng(result.latitude, result.longitude) searchPin = gp mapLibreMap?.let { ml -> ml.animateCamera(CameraUpdateFactory.newLatLngZoom(gp, 15.0)) } followLocation = false showSearchDialog = false } ) } showDraftPointMenu?.let { sel -> val idx = sel.index val draft = activeDraft val pt = if (draft != null) draft.points.getOrNull(idx) else visibleTrack.getOrNull(idx) AlertDialog( onDismissRequest = { showDraftPointMenu = null }, title = { Text(stringResource(R.string.explorer_track_point_title, idx + 1)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (pt != null) { Text( "%.6f, %.6f".format(java.util.Locale.US, pt.latitude, pt.longitude), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) ) } if (draft != null) { val isMarked = idx in draft.markedIndexes Button( onClick = { val newMarked = if (isMarked) draft.markedIndexes - idx else (draft.markedIndexes + idx).distinct().sorted() viewModel.saveDraft(draft.copy(markedIndexes = newMarked)) showDraftPointMenu = null }, colors = ButtonDefaults.buttonColors( containerColor = if (isMarked) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Bookmarks, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text( if (isMarked) stringResource(R.string.draft_point_unmark) else stringResource(R.string.draft_point_mark) ) } Button( onClick = { selectedTrackIndex = idx tapMode = TapModeMl.MoveTrackPoint(idx) showDraftPointMenu = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.OpenWith, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_action_move_tap)) } Button( onClick = { val ml = mapLibreMap if (ml != null) { val center = ml.cameraPosition.target val pts = draft.points.toMutableList() if (idx in pts.indices && center != null) { pts[idx] = pts[idx].copy( latitude = center.latitude, longitude = center.longitude ) viewModel.saveDraft(draft.copy(points = pts)) } } showDraftPointMenu = null }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.PinDrop, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_action_move_to_center)) } Button( onClick = { val pts = draft.points.toMutableList() if (idx in pts.indices) { pts.removeAt(idx) val newMarked = draft.markedIndexes .filter { it != idx } .map { if (it > idx) it - 1 else it } viewModel.saveDraft(draft.copy(points = pts, markedIndexes = newMarked)) } showDraftPointMenu = null }, enabled = !isRecording, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer ), modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.delete)) } } else { Text( stringResource(R.string.explorer_track_point_no_draft_hint), style = MaterialTheme.typography.bodySmall ) } } }, confirmButton = { TextButton(onClick = { showDraftPointMenu = null }) { Text(stringResource(R.string.cancel)) } } ) } if (showDraftsSheet) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { showDraftsSheet = false }, sheetState = sheetState ) { DraftsPanelMl( drafts = drafts, activeDraftId = activeDraftId, isRecording = isRecording, onActivate = { id -> activeDraftId = if (activeDraftId == id) null else id val ml = mapLibreMap val first = drafts.firstOrNull { it.id == id }?.points?.firstOrNull() if (ml != null && first != null) { ml.animateCamera( CameraUpdateFactory.newLatLngZoom( LatLng(first.latitude, first.longitude), 15.0 ) ) followLocation = false } }, onOpenFullEditor = { id -> onOpenDraft(id) }, onDelete = { id -> viewModel.deleteDraft(id) if (activeDraftId == id) activeDraftId = null }, onImport = { id -> val d = drafts.firstOrNull { it.id == id } ?: return@DraftsPanelMl viewModel.importDraftAsWaypoints(d, selectedTour) }, onClose = { showDraftsSheet = false } ) } } if (showStoryboardSheet) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { showStoryboardSheet = false }, sheetState = sheetState ) { StoryboardPanelMl( tourWaypoints = waypoints, currentTour = selectedTour, onSaveAsTour = { targetTour, orderedIds -> viewModel.applyStoryboardOrder(targetTour, orderedIds) showStoryboardSheet = false }, onClose = { showStoryboardSheet = false } ) } } // ─── Scaffold ──────────────────────────────────────────────────────────── Scaffold( topBar = { // v2.5.6 — Explorer TopBar im v2.5.x-Stil: // Dezente Surface-Fläche statt knalligem Primary-Teal, primary nur // als Akzent in Icons/Aktionen. Bessere Harmonie mit HomeScreen und // den neuen Tour-/Audio-Cards. TopAppBar( title = { if (currentMode != null && onModeChange != null) { MapModeSegmentedHeader( current = currentMode, onChange = onModeChange, subtitle = selectedTour, contentColor = MaterialTheme.colorScheme.onSurface ) } else { Column { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Filled.Explore, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.explorer_title)) } Text( text = selectedTour, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f) ) } } }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back) ) } }, actions = { IconButton(onClick = { showSearchDialog = true }) { Icon( Icons.Filled.Search, contentDescription = stringResource(R.string.search_place_action) ) } IconButton(onClick = { followLocation = !followLocation }) { Icon( if (followLocation) Icons.Filled.GpsFixed else Icons.Filled.GpsOff, contentDescription = if (followLocation) stringResource(R.string.explorer_follow_on) else stringResource(R.string.explorer_follow_off), tint = if (followLocation) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = { tapMode = if (tapMode is TapModeMl.PlaceNew) TapModeMl.None else TapModeMl.PlaceNew }) { Icon( Icons.Filled.TouchApp, contentDescription = stringResource(R.string.explorer_action_tap_place), tint = if (tapMode is TapModeMl.PlaceNew) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, titleContentColor = MaterialTheme.colorScheme.onSurface, navigationIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.primary ) ) }, floatingActionButton = { // v2.5.6 — FABs harmonisiert: ruhige, glasige Surfaces statt // knalliges Primary-Teal; Tonale Erhöhung sorgt für sichtbaren // Kontrast gegenüber der Karte ohne grelle Akzente. // v2.5.40 — Toolbar-Fix: Alle FABs in einer Column mit // genügend Abstand; kein Überlappen mit MapLayerFab (TopEnd). // Der Compass-Button ist neu (Bearing auf 0 zurücksetzen). val neutralFabContainer = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) val neutralFabContent = MaterialTheme.colorScheme.primary Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 8.dp) ) { // v2.5.40 — Kompass/Nordausrichten SmallFloatingActionButton( onClick = { val ml = mapLibreMap ?: return@SmallFloatingActionButton val pos = ml.cameraPosition ml.animateCamera( CameraUpdateFactory.newCameraPosition( CameraPosition.Builder(pos).bearing(0.0).build() ) ) }, containerColor = neutralFabContainer, contentColor = neutralFabContent ) { Icon( Icons.Filled.Navigation, contentDescription = "Nach Norden ausrichten" ) } SmallFloatingActionButton( onClick = { showDraftsSheet = true }, containerColor = neutralFabContainer, contentColor = neutralFabContent ) { Icon(Icons.Filled.Timeline, contentDescription = stringResource(R.string.explorer_drafts_open)) } SmallFloatingActionButton( onClick = { showStoryboardSheet = true }, containerColor = neutralFabContainer, contentColor = neutralFabContent ) { Icon(Icons.Filled.MovieFilter, contentDescription = stringResource(R.string.explorer_storyboard_open)) } SmallFloatingActionButton( onClick = { showRoutePlanner = true }, containerColor = neutralFabContainer, contentColor = neutralFabContent ) { Icon(Icons.Filled.AltRoute, contentDescription = stringResource(R.string.route_planner_open)) } // v2.5.3 — FAB für „Route zeichnen". Toggle: an = Tap setzt // stille Routenpunkte, aus = normale Karten-Tap-Logik. SmallFloatingActionButton( onClick = { manualRouteMode = !manualRouteMode tapMode = if (manualRouteMode) TapModeMl.DrawRoutePoint else TapModeMl.None }, containerColor = if (manualRouteMode) MaterialTheme.colorScheme.tertiaryContainer else neutralFabContainer, contentColor = if (manualRouteMode) MaterialTheme.colorScheme.onTertiaryContainer else neutralFabContent ) { Icon( Icons.Filled.Edit, contentDescription = stringResource(R.string.explorer_mode_draw_route) ) } if (isRecording) { ExtendedFloatingActionButton( onClick = { viewModel.stopTrackRecording() }, containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, icon = { Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.map_track_stop)) }, text = { Text(stringResource(R.string.map_track_recording)) } ) } else { SmallFloatingActionButton( onClick = { viewModel.startTrackRecording() }, containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer ) { Icon( Icons.Filled.FiberManualRecord, contentDescription = stringResource(R.string.map_track_start) ) } } FloatingActionButton( onClick = { val ml = mapLibreMap val center = ml?.cameraPosition?.target val pre = if (center != null) Pair(center.latitude, center.longitude) else if (lastLat != null && lastLng != null) Pair(lastLat!!, lastLng!!) else null editDialogWaypoint = null editDialogPrefill = pre showEditDialog = true }, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) { Icon( Icons.Filled.AddLocation, contentDescription = stringResource(R.string.explorer_add_waypoint) ) } SmallFloatingActionButton( onClick = { val ml = mapLibreMap ?: return@SmallFloatingActionButton val lat = lastLat val lng = lastLng if (lat != null && lng != null) { ml.animateCamera(CameraUpdateFactory.newLatLngZoom(LatLng(lat, lng), 17.0)) followLocation = true } else if (waypoints.isNotEmpty()) { val first = waypoints.first() ml.animateCamera( CameraUpdateFactory.newLatLngZoom( LatLng(first.latitude, first.longitude), 15.0 ) ) } }, containerColor = neutralFabContainer, contentColor = neutralFabContent ) { Icon( Icons.Filled.MyLocation, contentDescription = stringResource(R.string.explorer_my_location) ) } } } ) { inner -> Box( modifier = Modifier .fillMaxSize() .padding(inner) ) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> val initialCenter = if (waypoints.isNotEmpty()) { LatLng(waypoints.first().latitude, waypoints.first().longitude) } else { LatLng(51.1657, 10.4515) } MapView(ctx).also { mv -> mv.onCreate(null) mv.getMapAsync { ml -> mapLibreMap = ml ml.cameraPosition = CameraPosition.Builder() .target(initialCenter) .zoom(13.0) .build() ml.uiSettings.isLogoEnabled = false ml.uiSettings.isAttributionEnabled = false ml.uiSettings.setCompassEnabled(true) // v2.5.37 — Initialer Stil beim Map-Aufbau. // Vektor-Stile via OpenFreeMap-URI laden. val initialStyleBuilder = when (layerState.baseStyle) { MapBaseStyle.DARK -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/dark") MapBaseStyle.OUTDOOR, MapBaseStyle.TOPO -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/liberty") MapBaseStyle.CITY_3D, MapBaseStyle.TERRAIN_3D, MapBaseStyle.PERSPECTIVE -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron") MapBaseStyle.CLASSIC, MapBaseStyle.SATELLITE -> Style.Builder().fromJson(buildClassicOsmStyleJson()) else -> Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron") } ml.setStyle(initialStyleBuilder) { loadedStyle -> // 3D-Extrusion für CITY_3D beim initialen Laden. if (layerState.baseStyle == MapBaseStyle.CITY_3D || layerState.baseStyle == MapBaseStyle.TERRAIN_3D || layerState.baseStyle == MapBaseStyle.PERSPECTIVE) { try { addBuildingExtrusionIfAvailable(loadedStyle) } catch (_: Exception) {} val pos2 = ml.cameraPosition ml.cameraPosition = CameraPosition.Builder(pos2) .tilt(mapStyleSettings.perspectiveTilt.toDouble().coerceIn(0.0, 60.0)) .build() } styleReady = true } // Klick-Handler für Wegpunkt-/Track-Marker. ml.setOnMarkerClickListener { m -> val wp = waypointMarkerIdToWaypoint[m.id] if (wp != null) { actionMenuWaypoint = wp return@setOnMarkerClickListener true } val ti = trackMarkerIdToIndex[m.id] if (ti != null) { // v2.5.33 — Phantom-Track-Fix: Nur öffnen, wenn ein // gültiger aktiver Draft oder eine laufende Aufzeichnung // existiert. Ohne gültigen Kontext (verwaiste Marker) wird // der Tap still ignoriert, statt einen irreführenden // Bearbeitungsdialog mit 'Aktiviere zuerst einen Draft' // zu zeigen. if (activeDraft != null || isRecording) { showDraftPointMenu = DraftPointSelectionMl(ti) } return@setOnMarkerClickListener true } // v2.5.3 — Klick auf einen Routenpunkt der manuellen Route // öffnet einen einfachen Punkt-Dialog (Verschieben/Löschen). val ri = manualRouteMarkerIdToIndex[m.id] if (ri != null) { selectedRoutePointIndex = ri showRoutePointDialog = true return@setOnMarkerClickListener true } // v2.5.40 — Klick auf Explorer-POI-Marker val epoi = explorerPoiMarkerIdToResult[m.id] if (epoi != null) { selectedExplorerPoi = epoi return@setOnMarkerClickListener true } false } ml.addOnMapClickListener { p -> when (val m = tapMode) { is TapModeMl.PlaceNew -> { editDialogWaypoint = null editDialogPrefill = Pair(p.latitude, p.longitude) showEditDialog = true tapMode = TapModeMl.None true } is TapModeMl.MoveWaypoint -> { val target = allWaypoints.firstOrNull { it.id == m.id } if (target != null) { viewModel.upsert( target.copy(latitude = p.latitude, longitude = p.longitude) ) } tapMode = TapModeMl.None true } is TapModeMl.MoveTrackPoint -> { val draft = activeDraft if (draft != null) { val pts = draft.points.toMutableList() if (m.index in pts.indices) { pts[m.index] = pts[m.index].copy( latitude = p.latitude, longitude = p.longitude ) viewModel.saveDraft(draft.copy(points = pts)) } } tapMode = TapModeMl.None selectedTrackIndex = null true } is TapModeMl.DrawRoutePoint -> { // v2.5.3 — Stiller Routenpunkt: hängt nur an // die Polyline an. Kein Audio, kein Wegpunkt, // keine Clip-Länge. manualRoutePoints = manualRoutePoints + LatLng( p.latitude, p.longitude ) // Im Modus bleiben, damit weitere Punkte gesetzt // werden können — Verlassen via Cancel-Chip oder // Mode-Toggle. true } is TapModeMl.MoveRoutePoint -> { val pts = manualRoutePoints.toMutableList() if (m.index in pts.indices) { pts[m.index] = LatLng(p.latitude, p.longitude) manualRoutePoints = pts } tapMode = TapModeMl.None selectedRoutePointIndex = null true } TapModeMl.None -> false } } ml.addOnMapLongClickListener { p -> // v2.5.3 — Im manuellen Routemodus dient LongPress // ebenfalls dem Setzen eines Routenpunkts und nicht // dem Anlegen eines Audio-Wegpunkts. if (manualRouteMode || tapMode is TapModeMl.DrawRoutePoint) { manualRoutePoints = manualRoutePoints + LatLng( p.latitude, p.longitude ) return@addOnMapLongClickListener true } editDialogWaypoint = null editDialogPrefill = Pair(p.latitude, p.longitude) showEditDialog = true true } // Folow-disable bei manueller Bewegung: ml.addOnMoveListener(object : org.maplibre.android.maps.MapLibreMap.OnMoveListener { override fun onMoveBegin(detector: org.maplibre.android.gestures.MoveGestureDetector) { if (followLocation) { val lat = lastLat; val lng = lastLng if (lat != null && lng != null) { val tgt = ml.cameraPosition.target if (tgt != null) { val dLat = kotlin.math.abs(tgt.latitude - lat) val dLng = kotlin.math.abs(tgt.longitude - lng) if (dLat > 0.00040 || dLng > 0.00040) { followLocation = false } } } } } override fun onMove(detector: org.maplibre.android.gestures.MoveGestureDetector) {} override fun onMoveEnd(detector: org.maplibre.android.gestures.MoveGestureDetector) {} }) // v2.5.41 — Zoom-Tracking für zoom-abhängige POI-Label-Anzeige // v2.5.45 — Auto-POI-Load: debounced nach Kamera-Idle ml.addOnCameraIdleListener { currentMapZoom = ml.cameraPosition.zoom // Auto-Load nur wenn aktiviert, POI-Layer an, Zoom genügend if (autoPoiLoadEnabled && !explorerPoiLoading) { val cp = ml.cameraPosition val zoom = cp.zoom val cLat = cp.target?.latitude ?: return@addOnCameraIdleListener val cLon = cp.target?.longitude ?: return@addOnCameraIdleListener val activeCats = buildSet { if (layerState.poiLayers.showSights) add(de.waypointaudio.data.PoiCategory.SIGHTS) if (layerState.poiLayers.showGastronomy) add(de.waypointaudio.data.PoiCategory.GASTRONOMY) if (layerState.poiLayers.showMobility) add(de.waypointaudio.data.PoiCategory.MOBILITY) if (layerState.poiLayers.showInfrastructure) add(de.waypointaudio.data.PoiCategory.INFRASTRUCTURE) } if (de.waypointaudio.util.ExplorerPoiClient.shouldAutoLoad( zoom, cLat, cLon, lastAutoLoadLat, lastAutoLoadLon, lastAutoLoadZoom, lastAutoLoadCats, activeCats ) ) { // v2.5.46: Debounce erhöht auf 2.5 s (vorher 1.5 s) poiAutoLoadJob?.cancel() poiAutoLoadJob = scope.launch { delay(2500L) if (explorerPoiLoading) return@launch val bounds = ml.projection.visibleRegion.latLngBounds val bbox = de.waypointaudio.util.LatLngBbox( south = bounds.southWest.latitude, west = bounds.southWest.longitude, north = bounds.northEast.latitude, east = bounds.northEast.longitude ) explorerPoiLoading = true autoPoiStatus = null try { val results = de.waypointaudio.util.ExplorerPoiClient.loadPois( context = context, bbox = bbox, categories = activeCats, onRadiusClamped = {} ) explorerPoiResults = results lastAutoLoadLat = cLat lastAutoLoadLon = cLon lastAutoLoadZoom = zoom lastAutoLoadCats = activeCats val total = results.values.sumOf { it.size } autoPoiStatus = if (total == 0) null else "POIs: $total" } catch (e: Exception) { // Alten Bestand behalten, nur dezente Fehlermeldung autoPoiStatus = null explorerPoiSnackbar = "POI-Aktualisierung fehlgeschlagen." } finally { explorerPoiLoading = false } } } } } } mv.onStart() mv.onResume() mapView = mv } }, update = { /* state already wired via mapLibreMap */ } ) Surface( shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f), shadowElevation = 4.dp, modifier = Modifier .align(Alignment.TopCenter) .padding(top = 8.dp, start = 12.dp, end = 12.dp) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val tapHint: String? = when (tapMode) { is TapModeMl.PlaceNew -> stringResource(R.string.explorer_tap_place_active) is TapModeMl.MoveWaypoint -> stringResource(R.string.explorer_tap_move_active) is TapModeMl.MoveTrackPoint -> stringResource(R.string.explorer_tap_move_track_active) is TapModeMl.DrawRoutePoint -> stringResource(R.string.explorer_mode_draw_route_active) is TapModeMl.MoveRoutePoint -> stringResource(R.string.explorer_route_point_move_active) TapModeMl.None -> null } if (tapHint != null) { Icon( Icons.Filled.TouchApp, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.tertiary ) Text( tapHint, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { val statusIcon = when { isRecording -> Icons.Filled.FiberManualRecord activeDraft != null -> Icons.Filled.Timeline else -> Icons.Filled.Place } val statusText = when { isRecording -> stringResource( R.string.explorer_status_recording_compact, currentTrack.size ) activeDraft != null -> stringResource( R.string.explorer_status_draft_compact, activeDraft.markedCount, activeDraft.pointCount ) else -> stringResource( R.string.explorer_status_compact, waypoints.size ) } val statusTint = when { isRecording -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.primary } Icon( statusIcon, contentDescription = null, modifier = Modifier.size(16.dp), tint = statusTint ) Text( statusText, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) val acc = lastAccuracyM if (acc != null) { Text( "· ${stringResource(R.string.explorer_accuracy_meters, acc.toInt())}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) ) } } } } AnimatedVisibility( visible = tapMode !is TapModeMl.None, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 40.dp), enter = fadeIn() + slideInVertically(initialOffsetY = { it }), exit = fadeOut() + slideOutVertically(targetOffsetY = { it }) ) { FilterChip( selected = true, onClick = { // v2.5.3 — Cancel verlässt auch den manualRouteMode, // damit der violette FAB visuell wieder deaktiviert ist. if (tapMode is TapModeMl.DrawRoutePoint) manualRouteMode = false tapMode = TapModeMl.None }, label = { Text(stringResource(R.string.explorer_cancel_tap)) } ) } MapLayerFab( state = layerState, onStateChange = { layerState = it }, customSatelliteUrl = mapStyleSettings.customSatelliteUrl, onCustomSatelliteUrlChange = { url -> scope.launch { runCatching { mapStyleStore.setCustomSatelliteUrl(url) } } }, customTopoUrl = mapStyleSettings.customTopoUrl, onCustomTopoUrlChange = { url -> scope.launch { runCatching { mapStyleStore.setCustomTopoUrl(url) } } }, customTerrainDemUrl = mapStyleSettings.customTerrainDemUrl, onCustomTerrainDemUrlChange = { url -> scope.launch { runCatching { mapStyleStore.setCustomTerrainDemUrl(url) } } }, perspectiveTilt = mapStyleSettings.perspectiveTilt, onPerspectiveTiltChange = { tilt -> scope.launch { runCatching { mapStyleStore.setPerspectiveTilt(tilt) } } }, onShowRoutingInfo = { showRoutingInfo = true }, onShowRoutePlanner = { showRoutePlanner = true }, // v2.5.45 — Auto-Load-Toggle poiAutoLoadEnabled = autoPoiLoadEnabled, onPoiAutoLoadChange = { enabled -> autoPoiLoadEnabled = enabled if (!enabled) poiAutoLoadJob?.cancel() }, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 8.dp, end = 8.dp) ) // Renderer-Badge: zeigt deutlich, dass MapLibre als aktiver Renderer läuft. Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.92f), shadowElevation = 2.dp, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 6.dp, bottom = 6.dp) ) { Text( text = stringResource(R.string.explorer_renderer_maplibre_badge), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } // v2.5.38 — Kompaktes Attribution-Badge, ersetzt altes OsmAttributionBadge. // Pflichtangabe ist stets sichtbar, aber schöner und nicht überlagernd. MapCompactAttributionBadge( modifier = Modifier .align(Alignment.BottomStart) .padding(start = 6.dp, bottom = 6.dp) ) // v2.5.41 — POI-Legende: einklappbare Kategorie-Übersicht // Nur anzeigen wenn mindestens ein POI-Layer aktiv ist val anyPoiLayerActive = layerState.poiLayers.let { it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure } if (anyPoiLayerActive) { ExplorerPoiLegend( poiLayers = layerState.poiLayers, expanded = poiLegendExpanded, onToggle = { poiLegendExpanded = !poiLegendExpanded }, modifier = Modifier .align(Alignment.BottomStart) .padding(start = 6.dp, bottom = 30.dp) ) } // v2.5.45 — Explorer-POI-Bar (Auto-Load-Status + manueller Fallback) // Kompaktes Pill-Design: links Status/Ladeindikator, rechts manueller Reload-Button. // Nur sichtbar wenn mindestens ein POI-Layer aktiv ist. val anyPoiLayerActiveForBar = layerState.poiLayers.let { it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure } if (anyPoiLayerActiveForBar) { Surface( shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f), shadowElevation = 3.dp, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp) ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { // Status-Icon/Spinner if (explorerPoiLoading) { CircularProgressIndicator( modifier = Modifier.size(13.dp), strokeWidth = 1.8.dp, color = MaterialTheme.colorScheme.onSecondaryContainer ) } else { Icon( if (autoPoiLoadEnabled) Icons.Filled.Explore else Icons.Filled.Explore, contentDescription = null, modifier = Modifier.size(13.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer.copy( alpha = if (autoPoiLoadEnabled) 1f else 0.55f ) ) } // v2.5.46 — Status-Text: nur bei echtem Laden "wird geladen", // sonst ruhiger Status-Text val statusText = when { explorerPoiLoading -> "POIs laden…" autoPoiStatus != null -> autoPoiStatus!! autoPoiLoadEnabled && currentMapZoom < de.waypointaudio.util.ExplorerPoiClient.AUTO_LOAD_MIN_ZOOM -> "Hereinzoomen für Auto-Load" autoPoiLoadEnabled -> "Auto-Laden aktiv" else -> "POIs: manuell" } Text( text = statusText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) // Trennstrich Text( "·", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.5f) ) // Manueller Neu-Laden Button Text( text = "↻", style = MaterialTheme.typography.labelMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, color = if (explorerPoiLoading) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.clickable(enabled = !explorerPoiLoading) { val ml = mapLibreMap ?: return@clickable val bounds = ml.projection.visibleRegion.latLngBounds val bbox = LatLngBbox( south = bounds.southWest.latitude, west = bounds.southWest.longitude, north = bounds.northEast.latitude, east = bounds.northEast.longitude ) val cats = buildSet { if (layerState.poiLayers.showSights) add(PoiCategory.SIGHTS) if (layerState.poiLayers.showGastronomy) add(PoiCategory.GASTRONOMY) if (layerState.poiLayers.showMobility) add(PoiCategory.MOBILITY) if (layerState.poiLayers.showInfrastructure) add(PoiCategory.INFRASTRUCTURE) } // Manueller Reload: Cache leeren, Cursor zurücksetzen de.waypointaudio.util.ExplorerPoiClient.clearCache() lastAutoLoadCats = null explorerPoiLoading = true autoPoiStatus = null scope.launch { try { val results = ExplorerPoiClient.loadPois( context = context, bbox = bbox, categories = cats, onRadiusClamped = { explorerPoiSnackbar = "Kartenausschnitt zu groß – Abfrage auf max. 5 km begrenzt." } ) val total = results.values.sumOf { it.size } explorerPoiResults = results val cp = ml.cameraPosition lastAutoLoadLat = cp.target?.latitude lastAutoLoadLon = cp.target?.longitude lastAutoLoadZoom = cp.zoom lastAutoLoadCats = cats autoPoiStatus = if (total > 0) "$total" else null explorerPoiSnackbar = if (total == 0) "Keine POIs in diesem Bereich." else ExplorerPoiClient.summarize(results) } catch (e: Exception) { explorerPoiSnackbar = "POI-Laden fehlgeschlagen: ${e.message?.take(60) ?: "Netzwerkfehler"}" } finally { explorerPoiLoading = false } } } ) } } } // v2.5.40 — Snackbar-Host für Explorer-POI-Meldungen SnackbarHost( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 80.dp) ) } } // v2.5.40 — Explorer-POI-Detail-Sheet selectedExplorerPoi?.let { poi -> val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { selectedExplorerPoi = null }, sheetState = sheetState ) { Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 500.dp) .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp) .padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // Titel Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Filled.Place, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(Modifier.width(8.dp)) Text(poi.displayName, style = MaterialTheme.typography.titleMedium) } // Kategorie + Entfernung val distText = if (lastLat != null && lastLng != null) { val d = ExplorerPoiClient.haversineM(lastLat!!, lastLng!!, poi.latitude, poi.longitude) if (d < 1000) "%.0f m entfernt".format(d) else "%.1f km entfernt".format(d / 1000.0) } else null Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // v2.5.45 — Subtype-Label (konkreter) statt nur Kategorie Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.secondaryContainer ) { Text( poi.subtype.displayLabel, // z.B. "Burg/Schloss" statt "Sehenswürdigkeit" style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } if (distText != null) { Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { Text( distText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } } } // Koordinaten Text( "%.5f, %.5f".format(poi.latitude, poi.longitude), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) // OSM-Tags (kompakt, max. 6) if (poi.rawTags.isNotEmpty()) { Spacer(Modifier.height(2.dp)) Text( "OSM-Tags", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) poi.rawTags.entries.take(6).forEach { (k, v) -> Text( "$k = $v", style = MaterialTheme.typography.bodySmall ) } } // Attribution Text( "POI-Daten: OpenStreetMap/Overpass (ODbL)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f) ) Spacer(Modifier.height(4.dp)) // Aktionen Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TextButton(onClick = { // Als Wegpunkt anlegen val newWp = Waypoint( latitude = poi.latitude, longitude = poi.longitude, name = poi.displayName, tourName = selectedTour, ) viewModel.upsert(newWp) selectedExplorerPoi = null explorerPoiSnackbar = "\"${poi.displayName}\" als Wegpunkt angelegt." }) { Icon(Icons.Filled.AddLocation, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) Text("Als Wegpunkt") } TextButton(onClick = { selectedExplorerPoi = null }) { Text("Schließen") } } } } } DisposableEffect(Unit) { onDispose { runCatching { mapView?.onPause() mapView?.onStop() mapView?.onDestroy() } } } } private data class DraftPointSelectionMl(val index: Int) private sealed interface TapModeMl { data object None : TapModeMl data object PlaceNew : TapModeMl data class MoveWaypoint(val id: String) : TapModeMl data class MoveTrackPoint(val index: Int) : TapModeMl /** v2.5.3 — Manueller Routemodus: Tippen setzt einen stillen Routenpunkt. */ data object DrawRoutePoint : TapModeMl /** v2.5.3 — Verschieben eines Routenpunkts an Index [index] der Polyline. */ data class MoveRoutePoint(val index: Int) : TapModeMl } // ─────────────────────────────────────────────────────────────────────────── // Overlay-Renderer (MapLibre Annotation API) // ─────────────────────────────────────────────────────────────────────────── private fun clearAllAnnotationsCaches( waypointMarkers: MutableList, waypointMarkerIdToWaypoint: MutableMap, waypointRadiusPolygons: MutableList, trackMarkers: MutableList, trackMarkerIdToIndex: MutableMap, trackPolylines: MutableList, routePolylines: MutableList, audioLinePolylines: MutableList, manualRoutePolylines: MutableList? = null, manualRouteMarkers: MutableList? = null, manualRouteMarkerIdToIndex: MutableMap? = null, ) { waypointMarkers.clear() waypointMarkerIdToWaypoint.clear() waypointRadiusPolygons.clear() trackMarkers.clear() trackMarkerIdToIndex.clear() trackPolylines.clear() routePolylines.clear() audioLinePolylines.clear() manualRoutePolylines?.clear() manualRouteMarkers?.clear() manualRouteMarkerIdToIndex?.clear() } @Suppress("LongParameterList") private fun drawExplorerOverlaysMapLibre( ml: MapLibreMap, context: android.content.Context, waypoints: List, track: List, markedIndexes: List, selectedTrackIndex: Int?, primaryArgb: Int, trackArgb: Int, showRadius: Boolean, showAudioLength: Boolean, showAudioLines: Boolean, audioDurationsMs: Map, selectedId: String?, searchPin: LatLng?, clipLineGeometry: List?, waypointMarkers: MutableList, waypointMarkerIdToWaypoint: MutableMap, waypointRadiusPolygons: MutableList, trackMarkers: MutableList, trackMarkerIdToIndex: MutableMap, trackPolylines: MutableList, audioLinePolylines: MutableList, searchPinMarkerRef: (Marker?) -> Unit, currentSearchPinMarker: Marker?, ) { // Vorhandene eigene Annotations entfernen. for (m in waypointMarkers) runCatching { ml.removeMarker(m) } waypointMarkers.clear() waypointMarkerIdToWaypoint.clear() for (p in waypointRadiusPolygons) runCatching { ml.removePolygon(p) } waypointRadiusPolygons.clear() for (m in trackMarkers) runCatching { ml.removeMarker(m) } trackMarkers.clear() trackMarkerIdToIndex.clear() for (l in trackPolylines) runCatching { ml.removePolyline(l) } trackPolylines.clear() for (l in audioLinePolylines) runCatching { ml.removePolyline(l) } audioLinePolylines.clear() currentSearchPinMarker?.let { runCatching { ml.removeMarker(it) } } searchPinMarkerRef(null) val density = context.resources.displayMetrics.density val iconFactory = IconFactory.getInstance(context) // Track-Polyline (Halo + Linie). if (track.size >= 2) { val pts = track.map { LatLng(it.latitude, it.longitude) } val halo = ml.addPolyline( PolylineOptions() .addAll(pts) .color(0xFFFFFFFF.toInt()) .width(10f) .alpha(0.85f) ) val line = ml.addPolyline( PolylineOptions() .addAll(pts) .color(trackArgb) .width(6f) .alpha(1.0f) ) trackPolylines += halo trackPolylines += line } if (track.isNotEmpty()) { val markedSet = markedIndexes.toSet() track.forEachIndexed { idx, p -> val gp = LatLng(p.latitude, p.longitude) val isMarked = idx in markedSet val isSelected = idx == selectedTrackIndex val sizePx = when { isSelected -> 80 isMarked -> 64 else -> 44 } val color = when { isSelected -> 0xFFFF5252.toInt() isMarked -> 0xFFFFAB00.toInt() else -> trackArgb } val bm = trackPointBitmap(sizePx, color, isSelected) val marker = ml.addMarker( MarkerOptions() .position(gp) .icon(iconFactory.fromBitmap(bm)) ) trackMarkers += marker trackMarkerIdToIndex[marker.id] = idx } } // v2.4.5 — Clip-Längenlinien NUR entlang bekannter Geometrie (Track/Route), // sonst gar nicht. Pro Waypoint wird ein Abschnitt der Länge proportional // zur Audiodauer auf der Geometrie gezeichnet. // // Änderung gegenüber v2.4.4: Der Waypoint markiert nicht mehr die MITTE, // sondern den ANFANG des Clip-Abschnitts. Der Abschnitt erstreckt sich // von der Projektionsposition des Waypoints in Richtung Routenende // (nach vorne entlang der Route/Track-Richtung). Damit "wirkt" der // Waypoint visuell als Trigger-Start des Clips, nicht als dessen Mittelpunkt. // // Sonderfall: Wenn der Waypoint nahe dem Ende der Geometrie liegt und // nicht mehr genug Vorwärtsweg verbleibt, wird der Abschnitt nach hinten // verlängert, damit er sichtbar bleibt — eine Fantasielinie über das // Geometrieende hinaus zeichnen wir bewusst nicht. if (showAudioLines && waypoints.isNotEmpty() && clipLineGeometry != null && clipLineGeometry.size >= 2) { val cumulative = cumulativeMeters(clipLineGeometry) val totalLen = cumulative.last() for (wp in waypoints) { val durationMs = audioDurationsMs[wp.soundUri] ?: 0L if (durationMs <= 0L) continue val sec = durationMs / 1000.0 val segLen = (sec * 5.0).coerceIn(15.0, 250.0).coerceAtMost(totalLen) // anchor auf der Geometrie: nächster Punkt zu (wp.lat, wp.lng) val anchorM = projectOntoGeometry(clipLineGeometry, cumulative, wp.latitude, wp.longitude) // v2.4.5: Start AB Waypoint nach vorne entlang der Geometrie. var startM = anchorM var endM = startM + segLen if (endM > totalLen) { // Nahe dem Geometrieende: Abschnitt zurückziehen, damit er // ganz auf der Geometrie liegt (keine Linie ins Nichts). endM = totalLen startM = (endM - segLen).coerceAtLeast(0.0) } val segPoints = sliceGeometry(clipLineGeometry, cumulative, startM, endM) if (segPoints.size < 2) continue val bucket = WaypointAudioDuration.classify(durationMs) val color = when (bucket) { de.waypointaudio.util.AudioLengthBucket.SHORT -> 0xFF66BB6A.toInt() de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 0xFFFFA726.toInt() de.waypointaudio.util.AudioLengthBucket.LONG -> 0xFFE53935.toInt() else -> primaryArgb } val w = when (bucket) { de.waypointaudio.util.AudioLengthBucket.LONG -> 7f de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 5f else -> 4f } val halo = ml.addPolyline( PolylineOptions() .addAll(segPoints) .color(0xFFFFFFFF.toInt()) .width(w + 3f) .alpha(0.85f) ) val line = ml.addPolyline( PolylineOptions() .addAll(segPoints) .color(color) .width(w) .alpha(0.95f) ) audioLinePolylines += halo audioLinePolylines += line } } for (wp in waypoints) { val gp = LatLng(wp.latitude, wp.longitude) if (showRadius && wp.radiusMeters > 0) { val circle = ml.addPolygon( PolygonOptions() .addAll(circleLatLng(wp.latitude, wp.longitude, wp.radiusMeters.toDouble(), 48)) .fillColor((primaryArgb and 0x00FFFFFF) or 0x40000000) .strokeColor(primaryArgb) .alpha(0.5f) ) waypointRadiusPolygons += circle } val isSelected = wp.id == selectedId val durationMs = if (showAudioLength) { audioDurationsMs[wp.soundUri] ?: 0L } else 0L val bm = if (durationMs > 0L) { waypointDotWithRing(primaryArgb, density, isSelected, durationMs) } else { waypointDotPlain(primaryArgb, density, isSelected) } val marker = ml.addMarker( MarkerOptions() .position(gp) .title(wp.name.ifBlank { "Wegpunkt" }) .icon(iconFactory.fromBitmap(bm)) ) waypointMarkers += marker waypointMarkerIdToWaypoint[marker.id] = wp } if (searchPin != null) { val pinBm = makeSearchPinBitmapShared(96) val pinMarker = ml.addMarker( MarkerOptions() .position(searchPin) .icon(iconFactory.fromBitmap(pinBm)) ) searchPinMarkerRef(pinMarker) } } // ─────────────────────────────────────────────────────────────────────────── // Bitmap-Helfer (lokal, damit unabhängig vom osmdroid-Renderer) // ─────────────────────────────────────────────────────────────────────────── private fun explorerMlLocationPuckBitmap(context: android.content.Context): Bitmap? { return runCatching { val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck) ?: return@runCatching null val size = (48 * context.resources.displayMetrics.density).toInt() val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) bitmap }.getOrNull() } private fun trackPointBitmap(sizePx: Int, argb: Int, selected: Boolean): Bitmap { val size = sizePx.coerceAtLeast(16) val bm = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bm) val cx = size / 2f; val cy = size / 2f val outerR = size / 2f - 2f val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = 0xFFFFFFFF.toInt() alpha = 220 } canvas.drawCircle(cx, cy, outerR, halo) val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = argb } canvas.drawCircle(cx, cy, outerR - size * 0.18f, fill) if (selected) { val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE color = 0xFF000000.toInt() strokeWidth = (size * 0.08f).coerceAtLeast(3f) } canvas.drawCircle(cx, cy, outerR - size * 0.20f, ring) } return bm } /** * v2.4.6 — Baut aus dem aktuellen Routenplaner-Zustand eine [TourRoute]. * * Bevorzugt die aktuell berechnete OSRM-Routenlinie; fällt auf die Planlinie * zurück, wenn keine berechnete Route vorhanden ist. Gibt `null` zurück, * wenn überhaupt keine nutzbare Geometrie existiert — die UI verhindert * dann das Speichern. */ private fun buildTourRouteFromState( plan: RoutePlan, routeLine: List, routePlanline: List, distanceMeters: Double?, durationSeconds: Double?, endpoint: String, planlineSourceLabel: String, ): de.waypointaudio.data.TourRoute? { val useComputed = routeLine.size >= 2 val poly = when { useComputed -> routeLine routePlanline.size >= 2 -> routePlanline else -> return null } val routePoints = poly.map { de.waypointaudio.data.TourRoutePoint(lat = it.latitude, lon = it.longitude) } fun toStop(rs: RouteStop?): de.waypointaudio.data.TourRouteStop? = rs?.let { de.waypointaudio.data.TourRouteStop( latitude = it.latitude, longitude = it.longitude, label = it.label, sourceHint = it.source.name ) } val viaStops = plan.via.map { de.waypointaudio.data.TourRouteStop( latitude = it.latitude, longitude = it.longitude, label = it.label, sourceHint = it.source.name ) } return de.waypointaudio.data.TourRoute( start = toStop(plan.start), destination = toStop(plan.destination), via = viaStops, polyline = routePoints, // v2.5.2 — Profil aus dem aktuellen Plan übernehmen, damit gespeicherte // Tourrouten später wissen, ob sie Auto/Fahrrad/Fußgänger waren. profile = plan.profile.osrmKey, source = if (useComputed) de.waypointaudio.data.RouteSourceKind.OSRM else de.waypointaudio.data.RouteSourceKind.PLANLINE, endpoint = if (useComputed) endpoint else planlineSourceLabel, distanceMeters = if (useComputed) distanceMeters else null, durationSeconds = if (useComputed) durationSeconds else null ) } /** * v2.5.2 — Liefert die String-Ressource für das angegebene Routingprofil. * Wird sowohl für UI-Labels als auch für Fehlermeldungen benötigt. */ @androidx.annotation.StringRes private fun profileLabelRes(profile: RoutingProfile): Int = when (profile) { RoutingProfile.DRIVING -> R.string.route_planner_profile_driving RoutingProfile.CYCLING -> R.string.route_planner_profile_cycling RoutingProfile.WALKING -> R.string.route_planner_profile_walking } /** Kategorie eines Routen-Stopps — bestimmt Farbe/Form auf der Karte. */ private enum class RouteStopKind { START, VIA, DESTINATION } /** * v2.4.2 — Erzeugt einen unterscheidbaren Marker für Start/Via/Ziel der Route. * * Start : grüne Pin-Form mit Innenring * Via : orange Pin mit Nummern-Label (Indizes 1..n) * Ziel : roter Pin mit „Z“-Glyph * * Alle Marker sind kontrastreich und auf hell/dunkel/topo/satellit gut sichtbar. */ private fun routeStopBitmap( kind: RouteStopKind, context: android.content.Context, label: String? = null, ): Bitmap { val density = context.resources.displayMetrics.density val size = (44f * density).toInt().coerceAtLeast(36) val bm = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bm) val cx = size / 2f val cy = size / 2f val r = size * 0.36f val color = when (kind) { RouteStopKind.START -> 0xFF2E7D32.toInt() RouteStopKind.VIA -> 0xFFFB8C00.toInt() RouteStopKind.DESTINATION -> 0xFFC62828.toInt() } // Halo (weiß) val halo = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL this.color = 0xFFFFFFFF.toInt() } canvas.drawCircle(cx, cy, r + 4f * density, halo) val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL this.color = color } canvas.drawCircle(cx, cy, r, fill) val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = 2f * density this.color = 0xFF202020.toInt() } canvas.drawCircle(cx, cy, r, ring) // Glyph val glyph = when (kind) { RouteStopKind.START -> "S" RouteStopKind.VIA -> label ?: "•" RouteStopKind.DESTINATION -> "Z" } val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = 0xFFFFFFFF.toInt() textSize = r * 1.05f textAlign = Paint.Align.CENTER isFakeBoldText = true } val baseline = cy - (text.descent() + text.ascent()) / 2f canvas.drawText(glyph, cx, baseline, text) return bm } private fun waypointDotPlain( fillArgb: Int, density: Float, isSelected: Boolean ): Bitmap { val touchDp = if (isSelected) 48f else 38f val sizePx = (touchDp * density).toInt().coerceAtLeast(32) val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) val cx = sizePx / 2f val cy = sizePx / 2f val visibleRadius = (if (isSelected) 14f else 10f) * density val halo = (if (isSelected) 4f else 3f) * density val paint = Paint(Paint.ANTI_ALIAS_FLAG) paint.style = Paint.Style.FILL paint.color = 0xFFFFFFFF.toInt() canvas.drawCircle(cx, cy, visibleRadius + halo, paint) paint.color = fillArgb canvas.drawCircle(cx, cy, visibleRadius, paint) paint.style = Paint.Style.STROKE paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density paint.color = 0xFF202020.toInt() canvas.drawCircle(cx, cy, visibleRadius, paint) return bmp } private fun waypointDotWithRing( fillArgb: Int, density: Float, isSelected: Boolean, durationMs: Long ): Bitmap { val touchDp = if (isSelected) 60f else 50f val sizePx = (touchDp * density).toInt().coerceAtLeast(48) val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) val cx = sizePx / 2f val cy = sizePx / 2f val visibleRadius = (if (isSelected) 14f else 10f) * density val halo = (if (isSelected) 4f else 3f) * density val ringInner = visibleRadius + halo + 2f * density val ringOuter = ringInner + 4f * density val ringMid = (ringInner + ringOuter) / 2f val ringStroke = (ringOuter - ringInner) val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = ringStroke color = 0x33000000.toInt() } canvas.drawCircle(cx, cy, ringMid, track) val bucket = WaypointAudioDuration.classify(durationMs) val bucketColor = when (bucket) { de.waypointaudio.util.AudioLengthBucket.SHORT -> 0xFF66BB6A.toInt() de.waypointaudio.util.AudioLengthBucket.MEDIUM -> 0xFFFFA726.toInt() de.waypointaudio.util.AudioLengthBucket.LONG -> 0xFFE53935.toInt() else -> fillArgb } val fraction = WaypointAudioDuration.normalizedFraction(durationMs).coerceIn(0f, 1f) val sweep = 360f * fraction if (sweep > 0.5f) { val progress = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeCap = Paint.Cap.ROUND strokeWidth = ringStroke color = bucketColor } val rectF = android.graphics.RectF(cx - ringMid, cy - ringMid, cx + ringMid, cy + ringMid) canvas.drawArc(rectF, -90f, sweep, false, progress) } val paint = Paint(Paint.ANTI_ALIAS_FLAG) paint.style = Paint.Style.FILL paint.color = 0xFFFFFFFF.toInt() canvas.drawCircle(cx, cy, visibleRadius + halo, paint) paint.color = fillArgb canvas.drawCircle(cx, cy, visibleRadius, paint) paint.style = Paint.Style.STROKE paint.strokeWidth = (if (isSelected) 2.5f else 1.5f) * density paint.color = 0xFF202020.toInt() canvas.drawCircle(cx, cy, visibleRadius, paint) return bmp } // ─────────────────────────────────────────────────────────────────────────── // Geo-Helfer // ─────────────────────────────────────────────────────────────────────────── private fun circleLatLng( latDeg: Double, lonDeg: Double, radiusMeters: Double, steps: Int ): List { val out = mutableListOf() val earthRadius = 6_371_000.0 val lat0 = Math.toRadians(latDeg) val lon0 = Math.toRadians(lonDeg) val d = radiusMeters / earthRadius for (i in 0 until steps) { val bearing = Math.toRadians(i * 360.0 / steps) val lat1 = Math.asin( Math.sin(lat0) * Math.cos(d) + Math.cos(lat0) * Math.sin(d) * Math.cos(bearing) ) val lon1 = lon0 + Math.atan2( Math.sin(bearing) * Math.sin(d) * Math.cos(lat0), Math.cos(d) - Math.sin(lat0) * Math.sin(lat1) ) out += LatLng(Math.toDegrees(lat1), Math.toDegrees(lon1)) } out += out.first() return out } private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val r = 6_371_000.0 val phi1 = Math.toRadians(lat1) val phi2 = Math.toRadians(lat2) val dPhi = Math.toRadians(lat2 - lat1) val dLam = Math.toRadians(lon2 - lon1) val a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLam / 2) * Math.sin(dLam / 2) val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) return r * c } private fun cumulativeMeters(line: List): DoubleArray { val out = DoubleArray(line.size) var acc = 0.0 out[0] = 0.0 for (i in 1 until line.size) { acc += haversineMeters( line[i - 1].latitude, line[i - 1].longitude, line[i].latitude, line[i].longitude ) out[i] = acc } return out } private fun projectOntoGeometry( line: List, cumulative: DoubleArray, lat: Double, lon: Double ): Double { // Wir verwenden die Position des nächstgelegenen Linienknotens als Anker. // Das ist robust und ohne 2D-Projektion in Metern hinreichend genau. var bestI = 0 var bestDist = Double.MAX_VALUE for (i in line.indices) { val d = haversineMeters(lat, lon, line[i].latitude, line[i].longitude) if (d < bestDist) { bestDist = d bestI = i } } return cumulative[bestI] } private fun sliceGeometry( line: List, cumulative: DoubleArray, startM: Double, endM: Double ): List { if (line.size < 2) return emptyList() val totalLen = cumulative.last() val s = startM.coerceIn(0.0, totalLen) val e = endM.coerceIn(0.0, totalLen) if (e <= s) return emptyList() val out = mutableListOf() out += interpolateOnLine(line, cumulative, s) for (i in line.indices) { if (cumulative[i] > s && cumulative[i] < e) { out += line[i] } } out += interpolateOnLine(line, cumulative, e) return out } private fun interpolateOnLine( line: List, cumulative: DoubleArray, distanceM: Double ): LatLng { if (line.size == 1) return line[0] if (distanceM <= 0.0) return line.first() if (distanceM >= cumulative.last()) return line.last() var i = 1 while (i < cumulative.size && cumulative[i] < distanceM) i++ val a = line[i - 1] val b = line[i] val da = cumulative[i - 1] val db = cumulative[i] val t = if (db > da) (distanceM - da) / (db - da) else 0.0 return LatLng( a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t ) } // ─────────────────────────────────────────────────────────────────────────── // Style-Builder (v2.5.37) // ─────────────────────────────────────────────────────────────────────────── /** * v2.5.37 — Klassischer OSM-Raster-Style als JSON. * * Wird nur noch für MapBaseStyle.CLASSIC verwendet (Fallback). * Alle anderen Stile werden über OpenFreeMap-Style-URLs geladen * (Style.Builder().fromUri(url)). * * Attribution: © OpenStreetMap-Mitwirkende (ODbL). */ private fun buildClassicOsmStyleJson(): String { val osmTile = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" val osmAttr = "© OpenStreetMap-Mitwirkende (ODbL)" return """ { "version": 8, "name": "GPS2Audio Classic OSM v2.5.37", "sources": { "explorer-tiles": { "type": "raster", "tiles": ["$osmTile"], "tileSize": 256, "minzoom": 0, "maxzoom": 19, "attribution": "$osmAttr" } }, "layers": [ { "id": "background", "type": "background", "paint": { "background-color": "#F5F3EE" } }, { "id": "explorer-tiles-layer", "type": "raster", "source": "explorer-tiles", "minzoom": 0, "maxzoom": 22, "paint": { "raster-brightness-min": 0.0, "raster-brightness-max": 1.0, "raster-saturation": 0.0, "raster-contrast": 0.0 } } ] } """.trimIndent() } /** * v2.5.37 — Fügt Gebäude-Extrusion (fill-extrusion) zum geladenen MapLibre-Style hinzu, * falls eine building-kompatible Source vorhanden ist. * * Strategie: Sucht nach einem vorhandenen Layer mit Typ "fill" und Source-Layer "building" * im geladenen Style. Wenn vorhanden, wird ein fill-extrusion-Layer auf derselben Source * hinzugefügt. Bei fehlendem Layer oder Source: kein Crash, einfacher 2D-Fallback. * * OpenFreeMap Positron enthält building-Daten in der OpenMapTiles-Schema-Source. * * Die Extrusion-Höhe wird aus dem "height"-Attribut des building-Features bezogen * (Fallback: 10m). Dach-Abstand aus "min_height" (Fallback: 0m). */ private fun addBuildingExtrusionIfAvailable(style: Style) { // Prüfe ob bereits ein extrusion-Layer vorhanden ist (z.B. nach Doppelaufruf). val existingExtrusionId = "gps2audio-building-extrusion" if (style.getLayer(existingExtrusionId) != null) return // Im OpenMapTiles-Schema (verwendet von OpenFreeMap Positron) heißt die // Vektor-Source "openmaptiles" und der Source-Layer für Gebäude "building". val buildingSourceLayer = "building" // Suche VOR dem ersten Label/Symbol-Layer einzufügen, // damit Text über Gebäuden sichtbar bleibt. val layers = style.layers val firstSymbolLayerId = layers .firstOrNull { it is org.maplibre.android.style.layers.SymbolLayer } ?.id try { // Versuche, den Layer über die MapLibre-JSON-API hinzuzufügen. // Wir nutzen addLayerBelow falls ein Symbol-Layer existiert. if (firstSymbolLayerId != null) { style.addLayerBelow( org.maplibre.android.style.layers.FillExtrusionLayer(existingExtrusionId, "openmaptiles") .apply { sourceLayer = buildingSourceLayer minZoom = 14f setProperties( org.maplibre.android.style.layers.PropertyFactory.fillExtrusionColor( org.maplibre.android.style.expressions.Expression.interpolate( org.maplibre.android.style.expressions.Expression.linear(), org.maplibre.android.style.expressions.Expression.get("render_height"), org.maplibre.android.style.expressions.Expression.stop(0f, org.maplibre.android.style.expressions.Expression.color(android.graphics.Color.parseColor("#EAEEF2"))), org.maplibre.android.style.expressions.Expression.stop(200f, org.maplibre.android.style.expressions.Expression.color(android.graphics.Color.parseColor("#C8D4E0"))) ) ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionHeight( org.maplibre.android.style.expressions.Expression.interpolate( org.maplibre.android.style.expressions.Expression.linear(), org.maplibre.android.style.expressions.Expression.zoom(), org.maplibre.android.style.expressions.Expression.stop(14f, 0f), org.maplibre.android.style.expressions.Expression.stop(16f, org.maplibre.android.style.expressions.Expression.get("render_height")) ) ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionBase( org.maplibre.android.style.expressions.Expression.interpolate( org.maplibre.android.style.expressions.Expression.linear(), org.maplibre.android.style.expressions.Expression.zoom(), org.maplibre.android.style.expressions.Expression.stop(14f, 0f), org.maplibre.android.style.expressions.Expression.stop(16f, org.maplibre.android.style.expressions.Expression.get("render_min_height")) ) ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionOpacity(0.82f) ) }, firstSymbolLayerId ) } else { style.addLayer( org.maplibre.android.style.layers.FillExtrusionLayer(existingExtrusionId, "openmaptiles") .apply { sourceLayer = buildingSourceLayer minZoom = 14f setProperties( org.maplibre.android.style.layers.PropertyFactory.fillExtrusionColor( android.graphics.Color.parseColor("#EAEEF2") ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionHeight( org.maplibre.android.style.expressions.Expression.get("render_height") ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionBase( org.maplibre.android.style.expressions.Expression.get("render_min_height") ), org.maplibre.android.style.layers.PropertyFactory.fillExtrusionOpacity(0.82f) ) } ) } } catch (_: Exception) { // 2D-Fallback: Extrusion konnte nicht eingetragen werden (fehlende Source, falscher Schema). // Karte bleibt in 2D-Ansicht — kein Crash. } } // ─────────────────────────────────────────────────────────────────────────── // Drafts/Storyboard Panels — Delegation an die bewährten Implementierungen // in ExplorerScreen.kt (dort als `internal fun` exponiert), um Duplizierung // und Compose-Scope-Probleme zu vermeiden. // ─────────────────────────────────────────────────────────────────────────── @Composable private fun DraftsPanelMl( drafts: List, activeDraftId: String?, isRecording: Boolean, onActivate: (String) -> Unit, onOpenFullEditor: (String) -> Unit, onDelete: (String) -> Unit, onImport: (String) -> Unit, onClose: () -> Unit ) { DraftsPanel( drafts = drafts, activeDraftId = activeDraftId, isRecording = isRecording, onActivate = onActivate, onOpenFullEditor = onOpenFullEditor, onDelete = onDelete, onImport = onImport, onClose = onClose ) } @Composable private fun StoryboardPanelMl( tourWaypoints: List, currentTour: String, onSaveAsTour: (targetTour: String, orderedIds: List) -> Unit, onClose: () -> Unit ) { StoryboardPanel( tourWaypoints = tourWaypoints, currentTour = currentTour, onSaveAsTour = onSaveAsTour, onClose = onClose ) } // ─────────────────────────────────────────────────────────────────────────── // v2.5.38 — POI-Layer-Sichtbarkeit // ─────────────────────────────────────────────────────────────────────────── /** * Schaltet Symbol-Layer des geladenen MapLibre-Styles nach POI-Kategorie * sichtbar/unsichtbar. * * Strategie: Heuristische Layer-ID-Präfixe auf Basis des OpenMapTiles-Schemas, * das OpenFreeMap Positron, Dark und Liberty verwenden. Wenn ein Layer-ID nicht * existiert, passiert nichts (kein Crash). * * Einschränkungen (dokumentiert): * - Layer-IDs sind style-abhängig und können sich bei OpenFreeMap-Updates ändern. * - Die Heuristik deckt die häufigsten Symbol-Layer ab, nicht jeden Einzelfall. * - Im CLASSIC-Raster-Modus nicht anwendbar (kein Vektor-Layer). * * OpenMapTiles-Schema Layer-ID-Muster (häufig): * - Sehenswürdigkeiten: "poi-*", "tourism-*", "historic-*", "attraction-*" * - Infrastruktur: "amenity-*", "health-*", "emergency-*", "post-*" * - Gastronomie: "food-*", "catering-*", "restaurant-*" * - Mobilität: "transit-*", "transport-*", "railway-*", "bus-*", "parking-*" */ private fun applyPoiLayerVisibility( style: org.maplibre.android.maps.Style, poi: de.waypointaudio.data.PoiLayerSettings ) { val allLayers = runCatching { style.layers }.getOrNull() ?: return // Kategorien-Zuordnung: Layer-ID-Präfixe/Substring-Heuristik val sightsPrefixes = listOf( "poi", "tourism", "historic", "attraction", "landmark", "sightseeing", "monument", "museum", "zoo", "aquarium", "castle", "church", "chapel", "mosque", "temple", "shrine" ) val infrastructurePrefixes = listOf( "health", "hospital", "pharmacy", "clinic", "doctor", "emergency", "police", "fire", "post", "bank", "atm", "fuel", "amenity-fuel", "amenity-bank", "amenity-atm", "amenity-hospital", "amenity-pharmacy", "amenity-post" ) val gastronomyPrefixes = listOf( "food", "catering", "restaurant", "cafe", "fast-food", "fast_food", "bar", "pub", "biergarten", "ice-cream", "ice_cream", "bakery", "amenity-restaurant", "amenity-cafe", "amenity-bar", "amenity-pub", "amenity-fast" ) val mobilityPrefixes = listOf( "transit", "transport", "railway", "bus", "subway", "tram", "ferry", "taxi", "bicycle", "parking", "car-sharing", "car_sharing", "rental", "station", "stop", "aeroway", "airport", "helipad" ) for (layer in allLayers) { // Nur Symbol-Layer (POI-Icons/Labels) verarbeiten. if (layer !is org.maplibre.android.style.layers.SymbolLayer) continue val id = layer.id.lowercase() val isSights = sightsPrefixes.any { id.contains(it) } val isInfra = infrastructurePrefixes.any { id.contains(it) } val isGastro = gastronomyPrefixes.any { id.contains(it) } val isMobility = mobilityPrefixes.any { id.contains(it) } // Nur Layer anpassen die einer POI-Kategorie zugeordnet sind. // Nicht-POI-Symbol-Layer (Labels, Straßennamen etc.) werden nicht angefasst. if (!isSights && !isInfra && !isGastro && !isMobility) continue val visible = when { isSights -> poi.showSights isInfra -> poi.showInfrastructure isGastro -> poi.showGastronomy isMobility -> poi.showMobility else -> true } runCatching { layer.setProperties( org.maplibre.android.style.layers.PropertyFactory.visibility( if (visible) org.maplibre.android.style.layers.Property.VISIBLE else org.maplibre.android.style.layers.Property.NONE ) ) } } } // ─────────────────────────────────────────────────────────────────────────── // v2.5.38 — Kompaktes Attribution-Badge (Pflichtangabe, schöner als v2.5.37) // ─────────────────────────────────────────────────────────────────────────── /** * Kompaktes, anklickbares Attribution-Badge unten links auf der Karte. * * Zeigt "© OpenStreetMap · ODbL" — klickt auf openstreetmap.org/copyright. * Pflichtangabe darf nicht von Buttons überdeckt werden. * Design: semi-transparent, harmoniert mit dem Karten-Design. */ @Composable fun MapCompactAttributionBadge(modifier: Modifier = Modifier) { val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current androidx.compose.material3.Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.80f), modifier = modifier .clickable { runCatching { uriHandler.openUri("https://www.openstreetmap.org/copyright") } } ) { androidx.compose.foundation.layout.Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) ) { Text( text = "© OpenStreetMap · ODbL", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), ) } } } // ─────────────────────────────────────────────────────────────────────────── // v2.5.42 — Explorer-POI-Icon-Builder: klar unterscheidbare Kategorie-Marker // ─────────────────────────────────────────────────────────────────────────── /** * Erstellt ein klar unterscheidbares Bitmap-Icon für jeden POI-Typ. * * v2.5.42 — Fix für "alle blaue Punkte": * 1. Jede Kategorie hat EIGENE Farbe + EIGENE Form/Symbol-Kombo. * 2. Bitmaps haben kategorie-spezifische Sentinel-Pixel an definierter * Position, damit MapLibre IconFactory keinen Icon-Cache-Hit erzeugt * (gleiche Bitmap-Größe → gleicher Hash-Bucket ohne Sentinel). * 3. Kein Default-Blau: fillColor kommt ausschließlich aus dem * Kategorie-Mapping, kein Fallback auf die System-Primärfarbe. * * Kategorie-Schema: * SIGHTS — Türkis (#00BCD4) + Rauten-Shape + Stern-Symbol * GASTRONOMY — Orange (#FF7043) + Kreis + Tassen-Symbol (C) * MOBILITY — Indigo (#3F51B5) + Abgerundetes Quadrat + Bus-Symbol (B) * INFRASTRUCTURE — Koralle (#E53935) + Kreis + Plus-Symbol (+) */ /** * v2.5.45 — Subtype-Overload: rückwärtskompatibel (Default: GENERIC je Kategorie) */ private fun buildExplorerPoiIcon( category: de.waypointaudio.data.PoiCategory, subtype: de.waypointaudio.util.PoiSubtype = when (category) { de.waypointaudio.data.PoiCategory.SIGHTS -> de.waypointaudio.util.PoiSubtype.GENERIC_SIGHTS de.waypointaudio.data.PoiCategory.GASTRONOMY -> de.waypointaudio.util.PoiSubtype.GENERIC_GASTRONOMY de.waypointaudio.data.PoiCategory.MOBILITY -> de.waypointaudio.util.PoiSubtype.GENERIC_MOBILITY de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> de.waypointaudio.util.PoiSubtype.GENERIC_INFRASTRUCTURE } ): Bitmap { // v2.5.43 — Echte Canvas-Piktogramme statt Buchstaben/Zeichen // v2.5.45 — Subtype-Piktogramme für Sights/Gastro/Mobil/Infra // v2.5.46 — Größe von 56 auf 68 px erhöht (bessere Lesbarkeit auf Smartphone-Screens) val sizePx = 68 // Kategorie-Farben — dezent, App-Teal-Stil passend val fillColor = when (category) { de.waypointaudio.data.PoiCategory.SIGHTS -> 0xFF00897B.toInt() // Teal-Grün (Sehenswürdigkeit) de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xFFBF6600.toInt() // Amber-Braun (Gastronomie) de.waypointaudio.data.PoiCategory.MOBILITY -> 0xFF1565C0.toInt() // Dunkelblau (Mobilität) de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF558B2F.toInt() // Dunkelgrün (Infrastruktur/Info) } // v2.5.45 — Sentinel-Wert berücksichtigt Kategorie + Subtype-Ordinal, // um MapLibre-Icon-Cache-Kollisionen zwischen verschiedenen Subtypen derselben Kategorie zu verhindern. val categoryBase = when (category) { de.waypointaudio.data.PoiCategory.SIGHTS -> 0 de.waypointaudio.data.PoiCategory.GASTRONOMY -> 32 de.waypointaudio.data.PoiCategory.MOBILITY -> 64 de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 96 } val sentinelAlpha = ((categoryBase + subtype.ordinal) % 200 + 55).coerceIn(1, 254) val bmp = android.graphics.Bitmap.createBitmap(sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(bmp) val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) val cx = sizePx / 2f val cy = sizePx / 2f // ── Hintergrundform je Kategorie ───────────────────────────────────────── when (category) { de.waypointaudio.data.PoiCategory.SIGHTS -> { // Fünfzackiger Stern als Marker-Hintergrund val outerR = sizePx / 2f - 3f val innerR = outerR * 0.48f val path = android.graphics.Path() for (i in 0 until 10) { val angle = Math.toRadians((-90.0 + i * 36.0)) val r = if (i % 2 == 0) outerR else innerR val x = cx + (r * Math.cos(angle)).toFloat() val y = cy + (r * Math.sin(angle)).toFloat() if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) } path.close() // Schatten paint.color = 0x30000000.toInt() paint.style = android.graphics.Paint.Style.FILL canvas.save(); canvas.translate(1.5f, 1.5f); canvas.drawPath(path, paint); canvas.restore() // Füllung paint.color = fillColor canvas.drawPath(path, paint) // Weißer Rand paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.STROKE paint.strokeWidth = 2.5f canvas.drawPath(path, paint) } de.waypointaudio.data.PoiCategory.GASTRONOMY -> { // Kreis als Badge-Hintergrund val r = sizePx / 2f - 4f paint.color = 0x30000000.toInt() paint.style = android.graphics.Paint.Style.FILL canvas.drawCircle(cx + 1.5f, cy + 1.5f, r, paint) paint.color = fillColor canvas.drawCircle(cx, cy, r, paint) paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.STROKE paint.strokeWidth = 2.5f canvas.drawCircle(cx, cy, r, paint) } de.waypointaudio.data.PoiCategory.MOBILITY -> { // Abgerundetes Rechteck val margin = 5f val rect = android.graphics.RectF(margin, margin + 2f, sizePx - margin, sizePx - margin - 2f) val corner = 10f paint.color = 0x30000000.toInt() paint.style = android.graphics.Paint.Style.FILL val sRect = android.graphics.RectF(margin + 1.5f, margin + 3.5f, sizePx - margin + 1.5f, sizePx - margin - 0.5f) canvas.drawRoundRect(sRect, corner, corner, paint) paint.color = fillColor canvas.drawRoundRect(rect, corner, corner, paint) paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.STROKE paint.strokeWidth = 2.5f canvas.drawRoundRect(rect, corner, corner, paint) } de.waypointaudio.data.PoiCategory.INFRASTRUCTURE -> { // Kreis mit dickem Rand val r = sizePx / 2f - 4f paint.color = 0x30000000.toInt() paint.style = android.graphics.Paint.Style.FILL canvas.drawCircle(cx + 1.5f, cy + 1.5f, r, paint) paint.color = fillColor canvas.drawCircle(cx, cy, r, paint) paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.STROKE paint.strokeWidth = 3.5f canvas.drawCircle(cx, cy, r, paint) } } // ── Weißes Piktogramm per Canvas (v2.5.45: Subtype-Varianten) ────────────────── // v2.5.46: Strichstärke auf 2.8 erhöht (Icon-Größe 68 px statt 56 px) paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.FILL paint.strokeWidth = 2.8f when (subtype) { // ─── SIGHTS Subtypes ──────────────────────────────────────────────────────── de.waypointaudio.util.PoiSubtype.CASTLE -> { // Turm/Burg: Zinnenkranz + Tor paint.style = android.graphics.Paint.Style.FILL val tw = 8f; val th = 14f val tl = cx - tw/2f; val tt = cy - th/2f - 1f canvas.drawRect(tl, tt + 4f, tl + tw, tt + th, paint) for (zi in 0..2) { val zx = tl + zi * (tw/2f) - 0.5f canvas.drawRect(zx, tt, zx + 2.5f, tt + 4f, paint) } paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 1.8f val tgW = 4f canvas.drawArc(android.graphics.RectF(cx - tgW, tt + th - tgW*1.5f - 2f, cx + tgW, tt + th - 2f), 180f, -180f, false, paint) } de.waypointaudio.util.PoiSubtype.CHURCH -> { // Kreuz paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 2f, cy - 12f, cx + 2f, cy + 10f, paint) canvas.drawRect(cx - 7f, cy - 6f, cx + 7f, cy - 2f, paint) } de.waypointaudio.util.PoiSubtype.MONASTERY -> { // Kleines Kreuz + Bogen paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 1.8f, cy - 10f, cx + 1.8f, cy + 8f, paint) canvas.drawRect(cx - 6f, cy - 5f, cx + 6f, cy - 2f, paint) paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 1.8f canvas.drawArc(android.graphics.RectF(cx - 7f, cy - 1f, cx + 7f, cy + 10f), 0f, 180f, false, paint) } de.waypointaudio.util.PoiSubtype.MUSEUM -> { // Giebelhaus paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f val roofP = android.graphics.Path().apply { moveTo(cx, cy - 12f); lineTo(cx - 10f, cy - 4f); lineTo(cx + 10f, cy - 4f); close() } canvas.drawPath(roofP, paint) canvas.drawLine(cx - 10f, cy + 8f, cx + 10f, cy + 8f, paint) paint.strokeWidth = 1.8f for (si in 0..2) { val sx = cx - 7f + si * 7f; canvas.drawLine(sx, cy - 4f, sx, cy + 8f, paint) } } de.waypointaudio.util.PoiSubtype.VIEWPOINT -> { // Fernglas paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.2f canvas.drawCircle(cx - 5f, cy + 2f, 5f, paint) canvas.drawCircle(cx + 5f, cy + 2f, 5f, paint) paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 2f, cy - 8f, cx + 2f, cy - 3f, paint) } de.waypointaudio.util.PoiSubtype.MONUMENT, de.waypointaudio.util.PoiSubtype.HISTORIC_OTHER -> { // Säule paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 2.5f, cy - 9f, cx + 2.5f, cy + 7f, paint) canvas.drawRect(cx - 5f, cy - 11f, cx + 5f, cy - 8f, paint) canvas.drawRect(cx - 6f, cy + 6f, cx + 6f, cy + 9f, paint) } de.waypointaudio.util.PoiSubtype.RUIN -> { // Gebrochene Mauer paint.style = android.graphics.Paint.Style.FILL val rb = cy + 8f canvas.drawRect(cx - 10f, cy - 2f, cx - 4f, rb, paint) canvas.drawRect(cx - 10f, cy - 8f, cx - 7f, cy - 2f, paint) canvas.drawRect(cx + 2f, cy, cx + 10f, rb, paint) canvas.drawRect(cx + 5f, cy - 5f, cx + 9f, cy, paint) } de.waypointaudio.util.PoiSubtype.GALLERY -> { // Bilderrahmen paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.2f canvas.drawRoundRect(android.graphics.RectF(cx - 9f, cy - 9f, cx + 9f, cy + 9f), 2f, 2f, paint) paint.strokeWidth = 1.5f canvas.drawRect(cx - 5f, cy - 5f, cx + 5f, cy + 5f, paint) } de.waypointaudio.util.PoiSubtype.THEATRE -> { // Zwei Drama-Bogen paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f canvas.drawArc(android.graphics.RectF(cx - 10f, cy - 8f, cx - 1f, cy + 4f), 0f, 270f, false, paint) canvas.drawArc(android.graphics.RectF(cx + 1f, cy - 8f, cx + 10f, cy + 4f), 270f, 270f, false, paint) } de.waypointaudio.util.PoiSubtype.ZOO -> { // Pfoten-Symbol paint.style = android.graphics.Paint.Style.FILL canvas.drawCircle(cx, cy + 4f, 6f, paint) canvas.drawCircle(cx - 6f, cy - 3f, 3f, paint) canvas.drawCircle(cx, cy - 7f, 3f, paint) canvas.drawCircle(cx + 6f, cy - 3f, 3f, paint) } de.waypointaudio.util.PoiSubtype.GENERIC_SIGHTS, de.waypointaudio.util.PoiSubtype.ATTRACTION -> { // Monument-Nadel (wie v2.5.44) paint.style = android.graphics.Paint.Style.FILL val stTop = cy - 10f; val stBot = cy + 4f canvas.drawRect(cx - 1.75f, stTop, cx + 1.75f, stBot, paint) val roofPath = android.graphics.Path().apply { moveTo(cx, stTop - 5f); lineTo(cx - 5f, stTop); lineTo(cx + 5f, stTop); close() } canvas.drawPath(roofPath, paint) canvas.drawRect(cx - 6f, stBot, cx + 6f, stBot + 2.5f, paint) } // ─── GASTRONOMY Subtypes ──────────────────────────────────────────────────── de.waypointaudio.util.PoiSubtype.RESTAURANT -> { // Messer + Gabel paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f canvas.drawLine(cx - 5f, cy + 9f, cx - 5f, cy - 10f, paint) paint.strokeWidth = 1.5f canvas.drawLine(cx - 7f, cy - 10f, cx - 7f, cy - 4f, paint) canvas.drawLine(cx - 3f, cy - 10f, cx - 3f, cy - 4f, paint) paint.strokeWidth = 2.0f canvas.drawLine(cx + 5f, cy + 9f, cx + 5f, cy - 4f, paint) val knifePath = android.graphics.Path().apply { moveTo(cx + 5f, cy - 4f); lineTo(cx + 8f, cy - 10f); lineTo(cx + 5f, cy - 10f); close() } paint.style = android.graphics.Paint.Style.FILL; canvas.drawPath(knifePath, paint) } de.waypointaudio.util.PoiSubtype.CAFE -> { // Tasse mit Dampf val cupLeft = cx - 9f; val cupRight = cx + 9f; val cupTop = cy - 3f; val cupBot = cy + 9f val cupPath = android.graphics.Path().apply { moveTo(cupLeft + 2f, cupTop); lineTo(cupLeft, cupBot); lineTo(cupRight, cupBot); lineTo(cupRight - 2f, cupTop); close() } paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.3f canvas.drawPath(cupPath, paint) paint.strokeWidth = 2f; canvas.drawLine(cupLeft - 2f, cupBot + 2f, cupRight + 2f, cupBot + 2f, paint) canvas.drawArc(android.graphics.RectF(cupRight - 1f, cupTop + 4f, cupRight + 6f, cupBot - 2f), -90f, 180f, false, paint) paint.strokeWidth = 1.8f val steamPath = android.graphics.Path().apply { moveTo(cx - 3f, cupTop - 3f); quadTo(cx - 1f, cupTop - 7f, cx + 1f, cupTop - 3f) }; canvas.drawPath(steamPath, paint) } de.waypointaudio.util.PoiSubtype.BAR -> { // Bierglas paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f val glassPath = android.graphics.Path().apply { moveTo(cx - 7f, cy - 9f); lineTo(cx - 9f, cy + 9f); lineTo(cx + 9f, cy + 9f); lineTo(cx + 7f, cy - 9f); close() } canvas.drawPath(glassPath, paint) canvas.drawArc(android.graphics.RectF(cx + 7f, cy - 5f, cx + 13f, cy + 5f), -90f, 180f, false, paint) } de.waypointaudio.util.PoiSubtype.FAST_FOOD -> { // Burger-Silhouette paint.style = android.graphics.Paint.Style.FILL canvas.drawArc(android.graphics.RectF(cx - 9f, cy - 10f, cx + 9f, cy + 2f), 180f, 180f, true, paint) paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.8f canvas.drawLine(cx - 9f, cy + 2f, cx + 9f, cy + 2f, paint) canvas.drawLine(cx - 9f, cy + 6f, cx + 9f, cy + 6f, paint) paint.style = android.graphics.Paint.Style.FILL canvas.drawArc(android.graphics.RectF(cx - 9f, cy + 4f, cx + 9f, cy + 12f), 0f, 180f, true, paint) } de.waypointaudio.util.PoiSubtype.GENERIC_GASTRONOMY -> { // Tasse (Fallback, wie v2.5.44) val cupLeft = cx - 9f; val cupRight = cx + 9f; val cupTop = cy - 3f; val cupBot = cy + 9f val cupPath = android.graphics.Path().apply { moveTo(cupLeft + 2f, cupTop); lineTo(cupLeft, cupBot); lineTo(cupRight, cupBot); lineTo(cupRight - 2f, cupTop); close() } paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.3f canvas.drawPath(cupPath, paint) paint.strokeWidth = 2f; canvas.drawLine(cupLeft - 2f, cupBot + 2f, cupRight + 2f, cupBot + 2f, paint) canvas.drawArc(android.graphics.RectF(cupRight - 1f, cupTop + 4f, cupRight + 6f, cupBot - 2f), -90f, 180f, false, paint) } // ─── MOBILITY Subtypes ────────────────────────────────────────────────────── de.waypointaudio.util.PoiSubtype.PARKING -> { // "P"-Piktogramm paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 3f canvas.drawLine(cx - 5f, cy - 10f, cx - 5f, cy + 10f, paint) canvas.drawArc(android.graphics.RectF(cx - 5f, cy - 10f, cx + 8f, cy + 0f), -90f, 180f, false, paint) } de.waypointaudio.util.PoiSubtype.BUS_STOP -> { // Bus-Silhouette (wie v2.5.44) val busLeft = cx - 10f; val busRight = cx + 10f; val busTop = cy - 8f; val busBot = cy + 6f paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.2f canvas.drawRoundRect(android.graphics.RectF(busLeft, busTop, busRight, busBot), 3f, 3f, paint) canvas.drawLine(busLeft + 2f, busTop + 2f, busLeft + 2f, busTop + 7f, paint) paint.strokeWidth = 1.5f canvas.drawRoundRect(android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f), 1f, 1f, paint) canvas.drawRoundRect(android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f), 1f, 1f, paint) paint.strokeWidth = 2f canvas.drawCircle(busLeft + 5f, busBot + 1f, 3f, paint) canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint) } de.waypointaudio.util.PoiSubtype.TRAIN_STATION -> { // Zug (vereinfacht) paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f canvas.drawRoundRect(android.graphics.RectF(cx - 12f, cy - 7f, cx + 12f, cy + 5f), 3f, 3f, paint) paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 6f, cy - 11f, cx - 3f, cy - 7f, paint) paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 1.8f canvas.drawCircle(cx - 7f, cy + 7f, 3f, paint) canvas.drawCircle(cx + 2f, cy + 7f, 3f, paint) canvas.drawCircle(cx + 9f, cy + 7f, 3f, paint) } de.waypointaudio.util.PoiSubtype.TRAM_STOP -> { // Straßenbahn paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f canvas.drawRoundRect(android.graphics.RectF(cx - 10f, cy - 7f, cx + 10f, cy + 5f), 3f, 3f, paint) canvas.drawLine(cx - 10f, cy - 9f, cx + 10f, cy - 9f, paint) paint.strokeWidth = 1.5f; canvas.drawLine(cx, cy - 9f, cx, cy - 7f, paint) canvas.drawCircle(cx - 5f, cy + 7f, 2.5f, paint) canvas.drawCircle(cx + 5f, cy + 7f, 2.5f, paint) } de.waypointaudio.util.PoiSubtype.FUEL -> { // Zapfsäule paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f canvas.drawRect(cx - 6f, cy - 5f, cx + 6f, cy + 9f, paint) canvas.drawLine(cx - 6f, cy - 5f, cx - 10f, cy - 9f, paint) canvas.drawLine(cx - 10f, cy - 9f, cx - 10f, cy - 3f, paint) paint.strokeWidth = 1.5f; canvas.drawRect(cx - 4f, cy - 3f, cx + 4f, cy + 3f, paint) } de.waypointaudio.util.PoiSubtype.GENERIC_MOBILITY -> { // Bus (Fallback, wie v2.5.44) val busLeft = cx - 10f; val busRight = cx + 10f; val busTop = cy - 8f; val busBot = cy + 6f paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.2f canvas.drawRoundRect(android.graphics.RectF(busLeft, busTop, busRight, busBot), 3f, 3f, paint) canvas.drawLine(busLeft + 2f, busTop + 2f, busLeft + 2f, busTop + 7f, paint) paint.strokeWidth = 1.5f canvas.drawRoundRect(android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f), 1f, 1f, paint) canvas.drawRoundRect(android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f), 1f, 1f, paint) paint.strokeWidth = 2f canvas.drawCircle(busLeft + 5f, busBot + 1f, 3f, paint) canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint) } // ─── INFRASTRUCTURE Subtypes ──────────────────────────────────────────────── de.waypointaudio.util.PoiSubtype.PHARMACY -> { // Apotheken-Kreuz paint.style = android.graphics.Paint.Style.FILL canvas.drawRect(cx - 3f, cy - 11f, cx + 3f, cy + 11f, paint) canvas.drawRect(cx - 11f, cy - 3f, cx + 11f, cy + 3f, paint) } de.waypointaudio.util.PoiSubtype.HOSPITAL -> { // H-Symbol paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 3f canvas.drawLine(cx - 7f, cy - 10f, cx - 7f, cy + 10f, paint) canvas.drawLine(cx + 7f, cy - 10f, cx + 7f, cy + 10f, paint) canvas.drawLine(cx - 7f, cy, cx + 7f, cy, paint) } de.waypointaudio.util.PoiSubtype.TOILETS -> { // WC-Piktogramm (Person-Silhouette) paint.style = android.graphics.Paint.Style.FILL canvas.drawCircle(cx - 6f, cy - 9f, 3f, paint) val dressPath = android.graphics.Path().apply { moveTo(cx - 10f, cy + 8f); lineTo(cx - 6f, cy - 6f); lineTo(cx - 2f, cy + 8f); close() }; canvas.drawPath(dressPath, paint) canvas.drawCircle(cx + 6f, cy - 9f, 3f, paint) canvas.drawRect(cx + 4f, cy - 6f, cx + 8f, cy + 8f, paint) } de.waypointaudio.util.PoiSubtype.DRINKING_WATER -> { // Wassertropfen val dropPath = android.graphics.Path().apply { moveTo(cx, cy + 10f) cubicTo(cx - 10f, cy + 2f, cx - 10f, cy - 6f, cx, cy - 12f) cubicTo(cx + 10f, cy - 6f, cx + 10f, cy + 2f, cx, cy + 10f) } paint.style = android.graphics.Paint.Style.FILL; canvas.drawPath(dropPath, paint) } de.waypointaudio.util.PoiSubtype.GENERIC_INFRASTRUCTURE -> { // Info-i (wie v2.5.44) paint.style = android.graphics.Paint.Style.FILL canvas.drawCircle(cx, cy - 8f, 3.5f, paint) val stW2 = 4.5f canvas.drawRoundRect(android.graphics.RectF(cx - stW2/2f, cy - 2f, cx + stW2/2f, cy + 9f), 2f, 2f, paint) canvas.drawRoundRect(android.graphics.RectF(cx - 7f, cy + 8f, cx + 7f, cy + 10f), 2f, 2f, paint) } } // Sentinel-Pixel: kategorie-spezifischer Alpha-Wert verhindert MapLibre-Cache-Kollision bmp.setPixel(0, 0, (sentinelAlpha shl 24) or 0x000000) return bmp } private fun buildExplorerPoiIconWithLabel( category: de.waypointaudio.data.PoiCategory, subtype: de.waypointaudio.util.PoiSubtype = de.waypointaudio.util.PoiSubtype.GENERIC_SIGHTS, name: String ): Bitmap { val iconSize = 68 // v2.5.46: gleiche Größe wie buildExplorerPoiIcon (erhöht auf 68 px) val labelMaxChars = 14 val displayName = if (name.length > labelMaxChars) name.take(labelMaxChars - 1) + "…" else name val labelPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { textSize = 20f textAlign = android.graphics.Paint.Align.CENTER typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) isFakeBoldText = true } val textWidth = labelPaint.measureText(displayName).toInt() val pillW = (textWidth + 14).coerceAtLeast(iconSize) val pillH = 26 val pillMargin = 2 val totalH = iconSize + pillMargin + pillH val totalW = maxOf(iconSize, pillW) + 4 val bmp = android.graphics.Bitmap.createBitmap(totalW, totalH, android.graphics.Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(bmp) val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) // Zeichne Kategorie-Icon zentriert oben val iconBmp = buildExplorerPoiIcon(category, subtype) val iconLeft = (totalW - iconSize) / 2f canvas.drawBitmap(iconBmp, iconLeft, 0f, null) iconBmp.recycle() // Label-Pill darunter — gleiche Farbe wie Kategorie, leicht transparent val pillColor = when (category) { de.waypointaudio.data.PoiCategory.SIGHTS -> 0xCC00897B.toInt() // Teal-Grün passend zu Piktogramm de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xCCBF6600.toInt() // Amber-Braun passend zu Piktogramm de.waypointaudio.data.PoiCategory.MOBILITY -> 0xCC1565C0.toInt() // Dunkelblau passend zu Piktogramm de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xCC558B2F.toInt() // Dunkelgrün passend zu Piktogramm } val pillTop = iconSize + pillMargin.toFloat() val pillLeft = (totalW - pillW) / 2f val pillRight = pillLeft + pillW val pillBottom = pillTop + pillH paint.color = pillColor paint.style = android.graphics.Paint.Style.FILL val pillRect = android.graphics.RectF(pillLeft, pillTop, pillRight, pillBottom) canvas.drawRoundRect(pillRect, 7f, 7f, paint) // Label-Text labelPaint.color = 0xFFFFFFFF.toInt() val textBounds = android.graphics.Rect() labelPaint.getTextBounds(displayName, 0, displayName.length, textBounds) val textX = totalW / 2f val textY = pillTop + pillH / 2f + textBounds.height() / 2f - textBounds.bottom canvas.drawText(displayName, textX, textY, labelPaint) return bmp } // ─────────────────────────────────────────────────────────────────────────── // v2.5.41 — POI-Legende (einklappbar, kompakt, über Attribution) // ─────────────────────────────────────────────────────────────────────────── /** * Kompakte, einklappbare POI-Kategorie-Legende. * Zeigt farbige Kreise mit Kategorie-Symbol und Kurzlabel. * Toggle-Klick klappt die Legende auf/zu. * Positionierung: bottom-start, über der Attribution (nicht überlagern). */ @Composable private fun ExplorerPoiLegend( poiLayers: de.waypointaudio.data.PoiLayerSettings, expanded: Boolean, onToggle: () -> Unit, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) { // v2.5.43 — Legende mit PoiCategory-Referenz für Canvas-Piktogramme data class LegendEntry(val category: de.waypointaudio.data.PoiCategory, val color: Long, val label: String, val visible: Boolean) val entries = listOf( LegendEntry(de.waypointaudio.data.PoiCategory.SIGHTS, 0xFF00897B, "Sehensw.", poiLayers.showSights), LegendEntry(de.waypointaudio.data.PoiCategory.GASTRONOMY, 0xFFBF6600, "Gastro", poiLayers.showGastronomy), LegendEntry(de.waypointaudio.data.PoiCategory.MOBILITY, 0xFF1565C0, "Mobilit.", poiLayers.showMobility), LegendEntry(de.waypointaudio.data.PoiCategory.INFRASTRUCTURE, 0xFF558B2F, "Infra", poiLayers.showInfrastructure), ).filter { it.visible } if (entries.isEmpty()) return androidx.compose.material3.Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.90f), shadowElevation = 2.dp, modifier = modifier.clickable { onToggle() } ) { if (!expanded) { // Kompakt: nur Farbpunkte nebeneinander Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { Text( "POI", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) entries.forEach { entry -> androidx.compose.foundation.Canvas(modifier = Modifier.size(10.dp)) { drawCircle(androidx.compose.ui.graphics.Color(entry.color)) } } } } else { // Ausgeklappt: Einträge mit Symbol + Label Column( modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp) ) { entries.forEach { entry -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp) ) { // v2.5.43 — Farbiger Kategorie-Kreis (konsistent mit Mini-Punkt in Kompakt-Ansicht) androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) { drawCircle(color = androidx.compose.ui.graphics.Color(entry.color)) } Text( entry.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f) ) } } Text( "Symbole zeigen Untertypen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), modifier = Modifier.padding(top = 1.dp) ) Text( "antippen zum Einklappen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f), modifier = Modifier.padding(top = 0.dp) ) } } } }