4226 lines
204 KiB
Kotlin
4226 lines
204 KiB
Kotlin
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)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|