Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt
T
2026-05-30 19:37:32 +00:00

4226 lines
204 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, Long>() }
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<List<LatLng>>(emptyList()) }
var routeStatus by remember { mutableStateOf<String?>(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<Double?>(null) }
var lastComputedDurationSeconds by remember { mutableStateOf<Double?>(null) }
var lastComputedEndpoint by remember { mutableStateOf<String>("") }
// 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<LatLng> = 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<LatLng> = 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<MapView?>(null) }
var mapLibreMap by remember { mutableStateOf<MapLibreMap?>(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<Marker>() }
val waypointMarkerIdToWaypoint = remember { mutableMapOf<Long, Waypoint>() }
val waypointRadiusPolygons = remember { mutableListOf<Polygon>() }
val trackMarkers = remember { mutableListOf<Marker>() }
val trackMarkerIdToIndex = remember { mutableMapOf<Long, Int>() }
val trackPolylines = remember { mutableListOf<Polyline>() }
val routePolylines = remember { mutableListOf<Polyline>() }
val planlinePolylines = remember { mutableListOf<Polyline>() }
val routeStopMarkers = remember { mutableListOf<Marker>() }
val audioLinePolylines = remember { mutableListOf<Polyline>() }
// v2.5.3 — Annotations der manuell gezeichneten Route.
val manualRoutePolylines = remember { mutableListOf<Polyline>() }
val manualRouteMarkers = remember { mutableListOf<Marker>() }
val manualRouteMarkerIdToIndex = remember { mutableMapOf<Long, Int>() }
var myLocationMarker by remember { mutableStateOf<Marker?>(null) }
var accuracyPolygon by remember { mutableStateOf<Polygon?>(null) }
var searchPinMarker by remember { mutableStateOf<Marker?>(null) }
// v2.5.40 — Explorer-POI-State
val explorerPoiMarkers = remember { mutableListOf<Marker>() }
val explorerPoiMarkerIdToResult = remember { mutableMapOf<Long, ExplorerPoiResult>() }
var explorerPoiResults by remember { mutableStateOf<Map<PoiCategory, List<ExplorerPoiResult>>>(emptyMap()) }
var explorerPoiLoading by remember { mutableStateOf(false) }
var explorerPoiSnackbar by remember { mutableStateOf<String?>(null) }
var selectedExplorerPoi by remember { mutableStateOf<ExplorerPoiResult?>(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<Job?>(null) }
var lastAutoLoadLat by remember { mutableStateOf<Double?>(null) }
var lastAutoLoadLon by remember { mutableStateOf<Double?>(null) }
var lastAutoLoadZoom by remember { mutableStateOf<Double?>(null) }
var lastAutoLoadCats by remember { mutableStateOf<Set<de.waypointaudio.data.PoiCategory>?>(null) }
var autoPoiStatus by remember { mutableStateOf<String?>(null) } // kurzer Status-Text
var lastLat by remember { mutableStateOf<Double?>(null) }
var lastLng by remember { mutableStateOf<Double?>(null) }
var lastAccuracyM by remember { mutableStateOf<Float?>(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<Waypoint?>(null) }
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
var deleteConfirm by remember { mutableStateOf<Waypoint?>(null) }
var tapMode by remember { mutableStateOf<TapModeMl>(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<List<LatLng>>(emptyList()) }
var selectedRoutePointIndex by remember { mutableStateOf<Int?>(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<Pair<de.waypointaudio.data.PoiCategory, de.waypointaudio.util.ExplorerPoiResult>> =
explorerPoiResults.flatMap { (cat, pois) -> pois.map { cat to it } }
val totalCount = allPois.size
val sortedPois = allPois.sortedWith(
compareByDescending<Pair<de.waypointaudio.data.PoiCategory, de.waypointaudio.util.ExplorerPoiResult>> {
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<org.maplibre.android.annotations.Marker>()
val newMarkerIdToResult = mutableMapOf<Long, de.waypointaudio.util.ExplorerPoiResult>()
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<LatLng?>(null) }
var showDraftsSheet by remember { mutableStateOf(false) }
var showStoryboardSheet by remember { mutableStateOf(false) }
var showDraftPointMenu by remember { mutableStateOf<DraftPointSelectionMl?>(null) }
var activeDraftId by remember { mutableStateOf<String?>(null) }
val activeDraft = activeDraftId?.let { id -> drafts.firstOrNull { it.id == id } }
var selectedTrackIndex by remember { mutableStateOf<Int?>(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<GpsTrackPoint> = 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<Int> = 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<Double, Double>? = lastLat?.let { lat ->
lastLng?.let { lon -> lat to lon }
}
val mapCenterLatLon: Pair<Double, Double>? = 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<de.waypointaudio.data.PoiCategory> {
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<PoiCategory> {
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<Marker>,
waypointMarkerIdToWaypoint: MutableMap<Long, Waypoint>,
waypointRadiusPolygons: MutableList<Polygon>,
trackMarkers: MutableList<Marker>,
trackMarkerIdToIndex: MutableMap<Long, Int>,
trackPolylines: MutableList<Polyline>,
routePolylines: MutableList<Polyline>,
audioLinePolylines: MutableList<Polyline>,
manualRoutePolylines: MutableList<Polyline>? = null,
manualRouteMarkers: MutableList<Marker>? = null,
manualRouteMarkerIdToIndex: MutableMap<Long, Int>? = 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<Waypoint>,
track: List<GpsTrackPoint>,
markedIndexes: List<Int>,
selectedTrackIndex: Int?,
primaryArgb: Int,
trackArgb: Int,
showRadius: Boolean,
showAudioLength: Boolean,
showAudioLines: Boolean,
audioDurationsMs: Map<String, Long>,
selectedId: String?,
searchPin: LatLng?,
clipLineGeometry: List<LatLng>?,
waypointMarkers: MutableList<Marker>,
waypointMarkerIdToWaypoint: MutableMap<Long, Waypoint>,
waypointRadiusPolygons: MutableList<Polygon>,
trackMarkers: MutableList<Marker>,
trackMarkerIdToIndex: MutableMap<Long, Int>,
trackPolylines: MutableList<Polyline>,
audioLinePolylines: MutableList<Polyline>,
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<LatLng>,
routePlanline: List<LatLng>,
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<LatLng> {
val out = mutableListOf<LatLng>()
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<LatLng>): 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<LatLng>,
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<LatLng>,
cumulative: DoubleArray,
startM: Double,
endM: Double
): List<LatLng> {
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<LatLng>()
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<LatLng>,
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<TrackDraft>,
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<Waypoint>,
currentTour: String,
onSaveAsTour: (targetTour: String, orderedIds: List<String>) -> 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)
)
}
}
}
}