Compare commits

..

3 Commits

Author SHA1 Message Date
Perplexity Computer 2eeb086481 Release GPS2Audio v2.5.48 TrackDraft backup fix 2026-05-30 22:19:04 +00:00
Perplexity Computer 990b66e24f Release GPS2Audio v2.5.46 POI stability 2026-05-30 19:37:32 +00:00
Perplexity Computer 5cfc5aee1d Release GPS2Audio v2.5.45 Explorer POI autoload 2026-05-30 18:04:55 +00:00
11 changed files with 1006 additions and 249 deletions
+2 -2
View File
@@ -157,8 +157,8 @@ android {
// • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, // • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip,
// PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing, // PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing,
// Tour-/Waypoint-Kopien, Hintergrundwiedergabe. // Tour-/Waypoint-Kopien, Hintergrundwiedergabe.
versionCode = 98 versionCode = 102
versionName = "2.5.44" versionName = "2.5.48"
} }
val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE") val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE")
@@ -55,7 +55,7 @@ enum class MapBaseStyle(val key: String) {
PERSPECTIVE("perspective"); PERSPECTIVE("perspective");
companion object { companion object {
val DEFAULT = MODERN_2D val DEFAULT = CITY_3D
fun fromKey(key: String?): MapBaseStyle { fun fromKey(key: String?): MapBaseStyle {
return when (key) { return when (key) {
"modern_2d" -> MODERN_2D "modern_2d" -> MODERN_2D
@@ -9,6 +9,7 @@ import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourCover import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourPlaybackDefaults import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TourRoute import de.waypointaudio.data.TourRoute
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.effectiveClips import de.waypointaudio.data.effectiveClips
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
@@ -68,6 +69,10 @@ object BackupExportManager {
// v2.5.5 — Tour-Cover (Icon + Palette) sind ein reines UI-Detail pro Tour // v2.5.5 — Tour-Cover (Icon + Palette) sind ein reines UI-Detail pro Tour
// und wandern mit dem Tour-Export mit. // und wandern mit dem Tour-Export mit.
val tourCovers: Map<String, TourCover> = emptyMap(), val tourCovers: Map<String, TourCover> = emptyMap(),
// v2.5.48 — Echte TrackDraft-Objekte (Name, Notiz, Markierungen, Punkte).
// gpsTracks bleibt für die Live-Aufzeichnung weiterhin im Backup-Input,
// wird aber NICHT mehr in track_drafts.json geschrieben.
val trackDrafts: List<TrackDraft> = emptyList(),
val appVersionName: String = "", val appVersionName: String = "",
val appVersionCode: Int = 0 val appVersionCode: Int = 0
) )
@@ -197,13 +202,17 @@ object BackupExportManager {
} }
// ----- data/track_drafts.json ----- // ----- data/track_drafts.json -----
// v2.5.48 FIX: Exportiert die echten TrackDraft-Objekte (Name, Notiz,
// Markierungen, Punkte) statt der gpsTracks-Map. Altes Format
// ("tracks": { tourName: [GpsTrackPoint] }) wird beim Import
// weiterhin toleriert (Abwärtskompatibilität).
if (selection.trackDrafts) { if (selection.trackDrafts) {
val trackPayload = mapOf( val trackPayload = mapOf(
"tracks" to input.gpsTracks "drafts" to input.trackDrafts
) )
writeJsonEntry(zip, "data/track_drafts.json", trackPayload) writeJsonEntry(zip, "data/track_drafts.json", trackPayload)
val pointCount = input.gpsTracks.values.sumOf { it.size } val pointCount = input.trackDrafts.sumOf { it.points.size }
sectionsWritten.add("Track-Entwürfe (${input.gpsTracks.size} Touren, $pointCount Punkte)") sectionsWritten.add("Track-Entwürfe (${input.trackDrafts.size} Entwürfe, $pointCount Punkte)")
} }
// ----- data/music_settings.json + media/... ----- // ----- data/music_settings.json + media/... -----
@@ -15,6 +15,7 @@ import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourPlaybackDefaults import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TourCover import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourRoute import de.waypointaudio.data.TourRoute
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.WaypointMusicBehavior import de.waypointaudio.data.WaypointMusicBehavior
import java.io.File import java.io.File
@@ -102,6 +103,8 @@ object BackupImportManager {
val tourRoutesToWrite: Map<String, TourRoute> = emptyMap(), val tourRoutesToWrite: Map<String, TourRoute> = emptyMap(),
// v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover). // v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover).
val tourCoversToWrite: Map<String, TourCover> = emptyMap(), val tourCoversToWrite: Map<String, TourCover> = emptyMap(),
// v2.5.48 — Echte TrackDraft-Objekte, die als neue Drafts hinzugefügt werden sollen.
val trackDraftsToAdd: List<TrackDraft> = emptyList(),
val trackMinDistanceMeters: Int?, val trackMinDistanceMeters: Int?,
val newTourSelection: String?, val newTourSelection: String?,
val warnings: List<String>, val warnings: List<String>,
@@ -514,42 +517,71 @@ object BackupImportManager {
} }
// ---- Track-Entwürfe ---- // ---- Track-Entwürfe ----
// v2.5.48 FIX: Importiert echte TrackDraft-Objekte aus dem "drafts"-Array.
// Abwärtskompatibilität: Alte Backups schrieben fälschlicherweise die
// gpsTracks-Map unter "tracks" in track_drafts.json. Falls "drafts" fehlt
// aber "tracks" vorhanden ist, wird das alte Format erkannt und toleriert
// (kein Crash, keine Datenverluste an bestehenden Touren/Waypoints).
val tracksToWrite = mutableMapOf<String, List<GpsTrackPoint>>() val tracksToWrite = mutableMapOf<String, List<GpsTrackPoint>>()
val trackDraftsToAdd = mutableListOf<TrackDraft>()
var tracksImported = 0 var tracksImported = 0
if (selection.trackDrafts && draftsJson != null) { if (selection.trackDrafts && draftsJson != null) {
val obj = runCatching { val obj = runCatching {
JsonParser.parseString(draftsJson).asJsonObject JsonParser.parseString(draftsJson).asJsonObject
}.getOrNull() }.getOrNull()
val map: Map<String, List<GpsTrackPoint>> = if (obj != null) {
runCatching {
gson.fromJson<Map<String, List<GpsTrackPoint>>>(
obj.get("tracks"),
object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
) ?: emptyMap()
}.getOrDefault(emptyMap())
} else emptyMap()
map.forEach { (origTour, points) -> if (obj != null) {
val mappedTour = tourRename[origTour] // Neues Format (v2.5.48+): "drafts" enthält eine Liste echter TrackDraft-Objekte.
?: run { val hasDraftsKey = obj.has("drafts") && !obj.get("drafts").isJsonNull
// Tour war nicht im Tour-Import (z. B. wenn nur Drafts importiert werden):
// prüfen, ob Name kollidiert. if (hasDraftsKey) {
if (origTour == Waypoint.DEFAULT_TOUR_NAME) { // Neues Format: Liste von TrackDraft-Objekten
origTour val importedDrafts: List<TrackDraft> = runCatching {
} else if (origTour in resultTours) { gson.fromJson<List<TrackDraft>>(
origTour obj.get("drafts"),
} else { object : TypeToken<List<TrackDraft>>() {}.type
resultTours.add(origTour) ) ?: emptyList()
origTour }.getOrDefault(emptyList())
}
importedDrafts.forEach { draft ->
// Tourname-Mapping anwenden (für den Fall, dass Tour umbenannt wurde).
val origTour = draft.sourceTourName
val mappedTour = tourRename[origTour]
?: run {
if (origTour == Waypoint.DEFAULT_TOUR_NAME ||
origTour in resultTours
) {
origTour
} else {
resultTours.add(origTour)
origTour
}
}
// Neue UUID vergeben, damit importierte Drafts nie mit
// vorhandenen Drafts-IDs kollidieren.
val newDraft = draft.copy(
id = java.util.UUID.randomUUID().toString(),
sourceTourName = mappedTour,
updatedAt = System.currentTimeMillis()
)
trackDraftsToAdd.add(newDraft)
tracksImported++
} }
val existingPoints = existingTracks[mappedTour] ?: emptyList()
if (existingPoints.isEmpty()) {
// Bestehenden Track NICHT überschreiben.
tracksToWrite[mappedTour] = points
tracksImported++
} else { } else {
warnings.add("Track-Entwurf für Tour „$mappedTour\" bereits vorhanden nicht überschrieben.") // Altes (fehlerhaftes) Format: "tracks" enthält gpsTracks-Map.
// Tolerieren ohne Crash, aber nicht in Tracks oder Drafts importieren,
// da der Inhalt GPS-Track-Daten (nicht Draft-Metadaten) sind.
// Hinweis in Warnings ausgeben.
val hasTracksKey = obj.has("tracks") && !obj.get("tracks").isJsonNull
if (hasTracksKey) {
warnings.add(
"Hinweis: track_drafts.json aus älterem Backup-Format erkannt " +
"(enthält GPS-Tracks statt Draft-Objekte). " +
"TrackDraft-Namen/Notizen können nicht wiederhergestellt werden. " +
"Bestehende Touren und Wegpunkte bleiben unverändert."
)
}
// Keine Daten importieren aus altem, fehlerh. Format — kein Crash.
} }
} }
} }
@@ -636,6 +668,8 @@ object BackupImportManager {
musicToWrite = musicToWrite, musicToWrite = musicToWrite,
tourRoutesToWrite = tourRoutesToWrite, tourRoutesToWrite = tourRoutesToWrite,
tourCoversToWrite = tourCoversToWrite, tourCoversToWrite = tourCoversToWrite,
// v2.5.48 — Echte TrackDraft-Objekte weitergeben.
trackDraftsToAdd = trackDraftsToAdd,
trackMinDistanceMeters = trackMinDistanceMeters, trackMinDistanceMeters = trackMinDistanceMeters,
newTourSelection = newTourSelection, newTourSelection = newTourSelection,
warnings = warnings, warnings = warnings,
@@ -121,8 +121,11 @@ import de.waypointaudio.util.LatLngBbox
import de.waypointaudio.util.WaypointAudioDuration import de.waypointaudio.util.WaypointAudioDuration
import de.waypointaudio.viewmodel.WaypointViewModel import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import de.waypointaudio.util.PoiSubtype
import org.maplibre.android.annotations.IconFactory import org.maplibre.android.annotations.IconFactory
import org.maplibre.android.annotations.Marker import org.maplibre.android.annotations.Marker
import org.maplibre.android.annotations.MarkerOptions import org.maplibre.android.annotations.MarkerOptions
@@ -324,6 +327,15 @@ fun ExplorerScreenMapLibre(
var currentMapZoom by remember { mutableStateOf(13.0) } var currentMapZoom by remember { mutableStateOf(13.0) }
val snackbarHostState = remember { SnackbarHostState() } 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 lastLat by remember { mutableStateOf<Double?>(null) }
var lastLng by remember { mutableStateOf<Double?>(null) } var lastLng by remember { mutableStateOf<Double?>(null) }
var lastAccuracyM by remember { mutableStateOf<Float?>(null) } var lastAccuracyM by remember { mutableStateOf<Float?>(null) }
@@ -455,10 +467,14 @@ fun ExplorerScreenMapLibre(
} }
} }
// v2.5.38 — POI-Layer-Auswahl persistent speichern. // 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) { LaunchedEffect(layerState.poiLayers) {
if (styleHydrated && layerState.poiLayers != mapStyleSettings.poiLayers) { if (styleHydrated && layerState.poiLayers != mapStyleSettings.poiLayers) {
runCatching { mapStyleStore.setPoiLayers(layerState.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. // v2.5.38 — POI-Sichtbarkeit auf den geladenen MapLibre-Style anwenden.
// Greift auf heuristische Layer-ID-Präfixe des OpenMapTiles-Schemas zurück. // Greift auf heuristische Layer-ID-Präfixe des OpenMapTiles-Schemas zurück.
@@ -487,33 +503,92 @@ fun ExplorerScreenMapLibre(
// Schlüssel inkl. showPoiNames: Marker werden bei Toggle-Änderung neu gezeichnet. // Schlüssel inkl. showPoiNames: Marker werden bei Toggle-Änderung neu gezeichnet.
// Fix: Kategorie-Bitmap wird per buildExplorerPoiIcon(cat, showPoiNames, name) mit // Fix: Kategorie-Bitmap wird per buildExplorerPoiIcon(cat, showPoiNames, name) mit
// eindeutiger Kategorie-Kennung erzeugt — verhindert Icon-Cache-Kollisionen in MapLibre. // 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) { LaunchedEffect(explorerPoiResults, styleReady, showPoiNames) {
if (!styleReady) return@LaunchedEffect if (!styleReady) return@LaunchedEffect
val ml = mapLibreMap ?: return@LaunchedEffect val ml = mapLibreMap ?: return@LaunchedEffect
// Alte Explorer-POI-Marker entfernen 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.forEach { runCatching { ml.removeMarker(it) } }
explorerPoiMarkers.clear() explorerPoiMarkers.clear()
explorerPoiMarkerIdToResult.clear() explorerPoiMarkerIdToResult.clear()
if (explorerPoiResults.isEmpty()) return@LaunchedEffect
val iconFactory = IconFactory.getInstance(context) explorerPoiMarkers.addAll(newMarkers)
for ((cat, pois) in explorerPoiResults) { explorerPoiMarkerIdToResult.putAll(newMarkerIdToResult)
for (poi in pois) {
val iconBitmap = if (showPoiNames) {
buildExplorerPoiIconWithLabel(cat, poi.displayName)
} else {
buildExplorerPoiIcon(cat)
}
val icon = iconFactory.fromBitmap(iconBitmap)
val mo = MarkerOptions()
.position(LatLng(poi.latitude, poi.longitude))
.title(poi.displayName)
.snippet(poi.categoryLabel)
.icon(icon)
val marker = runCatching { ml.addMarker(mo) }.getOrNull() ?: continue
explorerPoiMarkers.add(marker)
explorerPoiMarkerIdToResult[marker.id] = poi
}
}
} }
var showSearchDialog by remember { mutableStateOf(false) } var showSearchDialog by remember { mutableStateOf(false) }
@@ -2131,8 +2206,65 @@ fun ExplorerScreenMapLibre(
override fun onMoveEnd(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.41 — Zoom-Tracking für zoom-abhängige POI-Label-Anzeige
// v2.5.45 — Auto-POI-Load: debounced nach Kamera-Idle
ml.addOnCameraIdleListener { ml.addOnCameraIdleListener {
currentMapZoom = ml.cameraPosition.zoom 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.onStart()
@@ -2274,6 +2406,12 @@ fun ExplorerScreenMapLibre(
}, },
onShowRoutingInfo = { showRoutingInfo = true }, onShowRoutingInfo = { showRoutingInfo = true },
onShowRoutePlanner = { showRoutePlanner = true }, onShowRoutePlanner = { showRoutePlanner = true },
// v2.5.45 — Auto-Load-Toggle
poiAutoLoadEnabled = autoPoiLoadEnabled,
onPoiAutoLoadChange = { enabled ->
autoPoiLoadEnabled = enabled
if (!enabled) poiAutoLoadJob?.cancel()
},
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp) .padding(top = 8.dp, end = 8.dp)
@@ -2320,42 +2458,73 @@ fun ExplorerScreenMapLibre(
) )
} }
// v2.5.40 — Explorer-POI-Lade-Button (mittig unten, über Attribution) // v2.5.45 — Explorer-POI-Bar (Auto-Load-Status + manueller Fallback)
if (layerState.poiLayers.let { // Kompaktes Pill-Design: links Status/Ladeindikator, rechts manueller Reload-Button.
it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure // 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( Surface(
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.95f), color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f),
shadowElevation = 3.dp, shadowElevation = 3.dp,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 32.dp) .padding(bottom = 32.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
// Status-Icon/Spinner
if (explorerPoiLoading) { if (explorerPoiLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(14.dp), modifier = Modifier.size(13.dp),
strokeWidth = 2.dp, strokeWidth = 1.8.dp,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} else { } else {
Icon( Icon(
Icons.Filled.Explore, if (autoPoiLoadEnabled) Icons.Filled.Explore else Icons.Filled.Explore,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(14.dp), modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer 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(
text = if (explorerPoiLoading) "POIs werden geladen …" text = statusText,
else "POIs im Kartenbereich laden", 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, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer, 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) { modifier = Modifier.clickable(enabled = !explorerPoiLoading) {
val ml = mapLibreMap ?: return@clickable val ml = mapLibreMap ?: return@clickable
val bounds = ml.projection.visibleRegion.latLngBounds val bounds = ml.projection.visibleRegion.latLngBounds
@@ -2371,7 +2540,11 @@ fun ExplorerScreenMapLibre(
if (layerState.poiLayers.showMobility) add(PoiCategory.MOBILITY) if (layerState.poiLayers.showMobility) add(PoiCategory.MOBILITY)
if (layerState.poiLayers.showInfrastructure) add(PoiCategory.INFRASTRUCTURE) if (layerState.poiLayers.showInfrastructure) add(PoiCategory.INFRASTRUCTURE)
} }
// Manueller Reload: Cache leeren, Cursor zurücksetzen
de.waypointaudio.util.ExplorerPoiClient.clearCache()
lastAutoLoadCats = null
explorerPoiLoading = true explorerPoiLoading = true
autoPoiStatus = null
scope.launch { scope.launch {
try { try {
val results = ExplorerPoiClient.loadPois( val results = ExplorerPoiClient.loadPois(
@@ -2379,17 +2552,21 @@ fun ExplorerScreenMapLibre(
bbox = bbox, bbox = bbox,
categories = cats, categories = cats,
onRadiusClamped = { onRadiusClamped = {
explorerPoiSnackbar = "Kartenausschnitt zu groß Abfrage auf max. 5 km Radius begrenzt." explorerPoiSnackbar = "Kartenausschnitt zu groß Abfrage auf max. 5 km begrenzt."
} }
) )
val total = results.values.sumOf { it.size } val total = results.values.sumOf { it.size }
explorerPoiResults = results explorerPoiResults = results
// v2.5.44 — POI-Zusammenfassung im Snackbar val cp = ml.cameraPosition
explorerPoiSnackbar = if (total == 0) { lastAutoLoadLat = cp.target?.latitude
"Keine POIs in diesem Bereich gefunden." lastAutoLoadLon = cp.target?.longitude
} else { 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) ExplorerPoiClient.summarize(results)
}
} catch (e: Exception) { } catch (e: Exception) {
explorerPoiSnackbar = "POI-Laden fehlgeschlagen: ${e.message?.take(60) ?: "Netzwerkfehler"}" explorerPoiSnackbar = "POI-Laden fehlgeschlagen: ${e.message?.take(60) ?: "Netzwerkfehler"}"
} finally { } finally {
@@ -2446,12 +2623,13 @@ fun ExplorerScreenMapLibre(
else "%.1f km entfernt".format(d / 1000.0) else "%.1f km entfernt".format(d / 1000.0)
} else null } else null
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// v2.5.45 — Subtype-Label (konkreter) statt nur Kategorie
Surface( Surface(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer color = MaterialTheme.colorScheme.secondaryContainer
) { ) {
Text( Text(
poi.categoryLabel, poi.subtype.displayLabel, // z.B. "Burg/Schloss" statt "Sehenswürdigkeit"
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer, color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)
@@ -3506,9 +3684,22 @@ fun MapCompactAttributionBadge(modifier: Modifier = Modifier) {
* MOBILITY — Indigo (#3F51B5) + Abgerundetes Quadrat + Bus-Symbol (B) * MOBILITY — Indigo (#3F51B5) + Abgerundetes Quadrat + Bus-Symbol (B)
* INFRASTRUCTURE — Koralle (#E53935) + Kreis + Plus-Symbol (+) * INFRASTRUCTURE — Koralle (#E53935) + Kreis + Plus-Symbol (+)
*/ */
private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): Bitmap { /**
* 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.43 — Echte Canvas-Piktogramme statt Buchstaben/Zeichen
val sizePx = 56 // 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 // Kategorie-Farben — dezent, App-Teal-Stil passend
val fillColor = when (category) { val fillColor = when (category) {
@@ -3518,13 +3709,15 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B
de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF558B2F.toInt() // Dunkelgrün (Infrastruktur/Info) de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF558B2F.toInt() // Dunkelgrün (Infrastruktur/Info)
} }
// Sentinel-Byte-Wert je Kategorie (eindeutige Pixel-Markierung für MapLibre-Cache) // v2.5.45 — Sentinel-Wert berücksichtigt Kategorie + Subtype-Ordinal,
val sentinelAlpha = when (category) { // um MapLibre-Icon-Cache-Kollisionen zwischen verschiedenen Subtypen derselben Kategorie zu verhindern.
de.waypointaudio.data.PoiCategory.SIGHTS -> 0xFC val categoryBase = when (category) {
de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xFD de.waypointaudio.data.PoiCategory.SIGHTS -> 0
de.waypointaudio.data.PoiCategory.MOBILITY -> 0xFE de.waypointaudio.data.PoiCategory.GASTRONOMY -> 32
de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF 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 bmp = android.graphics.Bitmap.createBitmap(sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bmp) val canvas = android.graphics.Canvas(bmp)
@@ -3605,101 +3798,270 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B
} }
} }
// ── Weißes Piktogramm per Canvas ─────────────────────────────────────────
// ── 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.color = 0xFFFFFFFF.toInt()
paint.style = android.graphics.Paint.Style.FILL paint.style = android.graphics.Paint.Style.FILL
paint.strokeWidth = 2.2f paint.strokeWidth = 2.8f
when (category) { when (subtype) {
de.waypointaudio.data.PoiCategory.SIGHTS -> { // ─── SIGHTS Subtypes ────────────────────────────────────────────────────────
// Stern-Marker: Stern-Hintergrund ist schon der Rahmen. de.waypointaudio.util.PoiSubtype.CASTLE -> {
// Piktogramm: kleiner innerer Punkt + Strahllinien wie eine Landmarke/Monument-Nadel // Turm/Burg: Zinnenkranz + Tor
// Vertikaler Stab (Monument-Pin) paint.style = android.graphics.Paint.Style.FILL
val stW = 3.5f val tw = 8f; val th = 14f
val stH = 14f val tl = cx - tw/2f; val tt = cy - th/2f - 1f
val stTop = cy - 10f canvas.drawRect(tl, tt + 4f, tl + tw, tt + th, paint)
val stBot = cy + 4f for (zi in 0..2) {
val stLeft = cx - stW / 2f val zx = tl + zi * (tw/2f) - 0.5f
canvas.drawRect(stLeft, stTop, stLeft + stW, stBot, paint) canvas.drawRect(zx, tt, zx + 2.5f, tt + 4f, paint)
// Kleines Dreieck-Dach oben (Turm/Monument) }
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 { val roofPath = android.graphics.Path().apply {
moveTo(cx, stTop - 5f) moveTo(cx, stTop - 5f); lineTo(cx - 5f, stTop); lineTo(cx + 5f, stTop); close()
lineTo(cx - 5f, stTop)
lineTo(cx + 5f, stTop)
close()
} }
canvas.drawPath(roofPath, paint) canvas.drawPath(roofPath, paint)
// Sockel/Basis unten
canvas.drawRect(cx - 6f, stBot, cx + 6f, stBot + 2.5f, paint) canvas.drawRect(cx - 6f, stBot, cx + 6f, stBot + 2.5f, paint)
} }
de.waypointaudio.data.PoiCategory.GASTRONOMY -> { // ─── GASTRONOMY Subtypes ────────────────────────────────────────────────────
// Tasse mit Henkel und Dampf-Linie de.waypointaudio.util.PoiSubtype.RESTAURANT -> {
val cupLeft = cx - 9f // Messer + Gabel
val cupRight = cx + 9f paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f
val cupTop = cy - 3f canvas.drawLine(cx - 5f, cy + 9f, cx - 5f, cy - 10f, paint)
val cupBot = cy + 9f paint.strokeWidth = 1.5f
// Tassenform: Trapez (breiter unten) canvas.drawLine(cx - 7f, cy - 10f, cx - 7f, cy - 4f, paint)
val cupPath = android.graphics.Path().apply { canvas.drawLine(cx - 3f, cy - 10f, cx - 3f, cy - 4f, paint)
moveTo(cupLeft + 2f, cupTop) paint.strokeWidth = 2.0f
lineTo(cupLeft, cupBot) canvas.drawLine(cx + 5f, cy + 9f, cx + 5f, cy - 4f, paint)
lineTo(cupRight, cupBot) val knifePath = android.graphics.Path().apply {
lineTo(cupRight - 2f, cupTop) moveTo(cx + 5f, cy - 4f); lineTo(cx + 8f, cy - 10f); lineTo(cx + 5f, cy - 10f); close()
close()
} }
paint.style = android.graphics.Paint.Style.STROKE paint.style = android.graphics.Paint.Style.FILL; canvas.drawPath(knifePath, paint)
paint.strokeWidth = 2.3f }
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) canvas.drawPath(cupPath, paint)
// Untertasse paint.strokeWidth = 2f; canvas.drawLine(cupLeft - 2f, cupBot + 2f, cupRight + 2f, cupBot + 2f, paint)
paint.strokeWidth = 2f canvas.drawArc(android.graphics.RectF(cupRight - 1f, cupTop + 4f, cupRight + 6f, cupBot - 2f), -90f, 180f, false, paint)
canvas.drawLine(cupLeft - 2f, cupBot + 2f, cupRight + 2f, cupBot + 2f, paint)
// Henkel rechts
val hRect = android.graphics.RectF(cupRight - 1f, cupTop + 4f, cupRight + 6f, cupBot - 2f)
canvas.drawArc(hRect, -90f, 180f, false, paint)
// Dampf-Wellenlinie oben (eine kleine Kurve)
paint.strokeWidth = 1.8f paint.strokeWidth = 1.8f
val steamPath = android.graphics.Path().apply { val steamPath = android.graphics.Path().apply {
moveTo(cx - 3f, cupTop - 3f) moveTo(cx - 3f, cupTop - 3f); quadTo(cx - 1f, cupTop - 7f, cx + 1f, cupTop - 3f)
quadTo(cx - 1f, cupTop - 7f, cx + 1f, cupTop - 3f) }; canvas.drawPath(steamPath, paint)
quadTo(cx + 3f, cupTop + 1f, cx + 5f, cupTop - 3f)
}
canvas.drawPath(steamPath, paint)
} }
de.waypointaudio.data.PoiCategory.MOBILITY -> { de.waypointaudio.util.PoiSubtype.BAR -> {
// Bus-Silhouette: rechteckiger Körper, Räder, Fenster // Bierglas
val busLeft = cx - 10f paint.style = android.graphics.Paint.Style.STROKE; paint.strokeWidth = 2.0f
val busRight = cx + 10f val glassPath = android.graphics.Path().apply {
val busTop = cy - 8f moveTo(cx - 7f, cy - 9f); lineTo(cx - 9f, cy + 9f); lineTo(cx + 9f, cy + 9f); lineTo(cx + 7f, cy - 9f); close()
val busBot = cy + 6f }
// Buskarosserie canvas.drawPath(glassPath, paint)
paint.style = android.graphics.Paint.Style.STROKE canvas.drawArc(android.graphics.RectF(cx + 7f, cy - 5f, cx + 13f, cy + 5f), -90f, 180f, false, paint)
paint.strokeWidth = 2.2f }
val busRect = android.graphics.RectF(busLeft, busTop, busRight, busBot) de.waypointaudio.util.PoiSubtype.FAST_FOOD -> {
canvas.drawRoundRect(busRect, 3f, 3f, paint) // Burger-Silhouette
// Frontscheibe (linke Seite) 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) canvas.drawLine(busLeft + 2f, busTop + 2f, busLeft + 2f, busTop + 7f, paint)
// Fenster 1
val w1 = android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f)
paint.strokeWidth = 1.5f paint.strokeWidth = 1.5f
canvas.drawRoundRect(w1, 1f, 1f, paint) canvas.drawRoundRect(android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f), 1f, 1f, paint)
// Fenster 2 canvas.drawRoundRect(android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f), 1f, 1f, paint)
val w2 = android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f)
canvas.drawRoundRect(w2, 1f, 1f, paint)
// Rad links
paint.strokeWidth = 2f paint.strokeWidth = 2f
canvas.drawCircle(busLeft + 5f, busBot + 1f, 3f, paint) canvas.drawCircle(busLeft + 5f, busBot + 1f, 3f, paint)
// Rad rechts
canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint) canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint)
} }
de.waypointaudio.data.PoiCategory.INFRASTRUCTURE -> { de.waypointaudio.util.PoiSubtype.TRAIN_STATION -> {
// Info-"i"-Piktogramm: großer Punkt oben, vertikaler Stab unten // 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 paint.style = android.graphics.Paint.Style.FILL
// Punkt oben
canvas.drawCircle(cx, cy - 8f, 3.5f, paint) canvas.drawCircle(cx, cy - 8f, 3.5f, paint)
// Stab unten — leicht breiter, abgerundet
val stW2 = 4.5f val stW2 = 4.5f
val rect2 = android.graphics.RectF(cx - stW2 / 2f, cy - 2f, cx + stW2 / 2f, cy + 9f) canvas.drawRoundRect(android.graphics.RectF(cx - stW2/2f, cy - 2f, cx + stW2/2f, cy + 9f), 2f, 2f, paint)
canvas.drawRoundRect(rect2, 2f, 2f, paint)
// Basis-Strich
canvas.drawRoundRect(android.graphics.RectF(cx - 7f, cy + 8f, cx + 7f, cy + 10f), 2f, 2f, paint) canvas.drawRoundRect(android.graphics.RectF(cx - 7f, cy + 8f, cx + 7f, cy + 10f), 2f, 2f, paint)
} }
} }
@@ -3712,9 +4074,10 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B
private fun buildExplorerPoiIconWithLabel( private fun buildExplorerPoiIconWithLabel(
category: de.waypointaudio.data.PoiCategory, category: de.waypointaudio.data.PoiCategory,
subtype: de.waypointaudio.util.PoiSubtype = de.waypointaudio.util.PoiSubtype.GENERIC_SIGHTS,
name: String name: String
): Bitmap { ): Bitmap {
val iconSize = 56 // gleiche Größe wie buildExplorerPoiIcon val iconSize = 68 // v2.5.46: gleiche Größe wie buildExplorerPoiIcon (erhöht auf 68 px)
val labelMaxChars = 14 val labelMaxChars = 14
val displayName = if (name.length > labelMaxChars) name.take(labelMaxChars - 1) + "" else name val displayName = if (name.length > labelMaxChars) name.take(labelMaxChars - 1) + "" else name
@@ -3736,7 +4099,7 @@ private fun buildExplorerPoiIconWithLabel(
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
// Zeichne Kategorie-Icon zentriert oben // Zeichne Kategorie-Icon zentriert oben
val iconBmp = buildExplorerPoiIcon(category) val iconBmp = buildExplorerPoiIcon(category, subtype)
val iconLeft = (totalW - iconSize) / 2f val iconLeft = (totalW - iconSize) / 2f
canvas.drawBitmap(iconBmp, iconLeft, 0f, null) canvas.drawBitmap(iconBmp, iconLeft, 0f, null)
iconBmp.recycle() iconBmp.recycle()
@@ -3844,11 +4207,17 @@ private fun ExplorerPoiLegend(
) )
} }
} }
Text(
"Symbole zeigen Untertypen",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 1.dp)
)
Text( Text(
"antippen zum Einklappen", "antippen zum Einklappen",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f),
modifier = Modifier.padding(top = 1.dp) modifier = Modifier.padding(top = 0.dp)
) )
} }
} }
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AltRoute import androidx.compose.material.icons.filled.AltRoute
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.GpsFixed import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
@@ -105,6 +106,9 @@ fun MapLayerFab(
onPerspectiveTiltChange: (Int) -> Unit = {}, onPerspectiveTiltChange: (Int) -> Unit = {},
onShowRoutingInfo: () -> Unit = {}, onShowRoutingInfo: () -> Unit = {},
onShowRoutePlanner: () -> Unit = {}, onShowRoutePlanner: () -> Unit = {},
// v2.5.45 — Auto-POI-Load-Toggle
poiAutoLoadEnabled: Boolean = true,
onPoiAutoLoadChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showSheet by remember { mutableStateOf(false) } var showSheet by remember { mutableStateOf(false) }
@@ -490,6 +494,19 @@ fun MapLayerFab(
) )
} }
) )
// v2.5.45 — Auto-POI-Laden Toggle
LayerSwitchRow(
icon = Icons.Filled.Explore,
label = "POIs automatisch nachladen",
checked = poiAutoLoadEnabled,
onCheckedChange = onPoiAutoLoadChange
)
Text(
"Auto-Laden: POIs werden beim Kartenverschieben aktualisiert (ab Zoom 12)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.padding(start = 28.dp)
)
} }
} }
@@ -11,6 +11,87 @@ import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import kotlin.math.* import kotlin.math.*
/**
* POI-Untertyp — v2.5.45.
*
* Abgeleitet aus OSM-Tags (amenity, tourism, historic, railway, highway …).
* Wird für Marker-Piktogramm-Varianten und Detail-Sheet genutzt.
* KEIN Persistenzmodell-Einfluss: nur In-Memory/Runtime.
*
* Für SIGHTS detailliert (Burg, Kirche, Museum, Aussicht, Denkmal …);
* für GASTRONOMY (Restaurant, Café, Bar, Schnellimbiss);
* für MOBILITY und INFRASTRUCTURE: wichtigste Typen.
*/
enum class PoiSubtype {
// SIGHTS
CASTLE, // historic=castle|palace|fort
RUIN, // historic=ruins|archaeological_site
CHURCH, // amenity=place_of_worship + religion=christian | historic=church|chapel|monastery
MONASTERY, // historic=monastery
MUSEUM, // tourism=museum
VIEWPOINT, // tourism=viewpoint
MONUMENT, // historic=monument|memorial
GALLERY, // tourism=gallery|artwork
THEATRE, // amenity=theatre|cinema
ZOO, // tourism=zoo|theme_park
ATTRACTION, // tourism=attraction (generic)
HISTORIC_OTHER, // historic=* (other)
// GASTRONOMY
RESTAURANT, // amenity=restaurant
CAFE, // amenity=cafe
BAR, // amenity=bar|pub|biergarten
FAST_FOOD, // amenity=fast_food|food_court|ice_cream
// MOBILITY
PARKING, // amenity=parking
BUS_STOP, // highway=bus_stop
TRAIN_STATION, // railway=station|halt
TRAM_STOP, // railway=tram_stop
FUEL, // amenity=fuel|charging_station
// INFRASTRUCTURE
PHARMACY, // amenity=pharmacy
HOSPITAL, // amenity=hospital|clinic
TOILETS, // amenity=toilets
DRINKING_WATER, // amenity=drinking_water
// Generic fallback per category
GENERIC_SIGHTS,
GENERIC_GASTRONOMY,
GENERIC_MOBILITY,
GENERIC_INFRASTRUCTURE;
/** Menschenlesbarer Label für das Detail-Sheet. */
val displayLabel: String get() = when (this) {
CASTLE -> "Burg/Schloss"
RUIN -> "Ruine/Archäologie"
CHURCH -> "Kirche/Gotteshaus"
MONASTERY -> "Kloster"
MUSEUM -> "Museum"
VIEWPOINT -> "Aussichtspunkt"
MONUMENT -> "Denkmal/Gedenkstätte"
GALLERY -> "Galerie/Kunstwerk"
THEATRE -> "Theater/Kino"
ZOO -> "Zoo/Freizeitpark"
ATTRACTION -> "Sehenswürdigkeit"
HISTORIC_OTHER -> "Historisch"
RESTAURANT -> "Restaurant"
CAFE -> "Café"
BAR -> "Bar/Pub"
FAST_FOOD -> "Schnellimbiss"
PARKING -> "Parkplatz"
BUS_STOP -> "Bushaltestelle"
TRAIN_STATION -> "Bahnhof"
TRAM_STOP -> "Straßenbahnhaltestelle"
FUEL -> "Tankstelle/Ladestation"
PHARMACY -> "Apotheke"
HOSPITAL -> "Krankenhaus/Klinik"
TOILETS -> "Toiletten"
DRINKING_WATER -> "Trinkwasser"
GENERIC_SIGHTS -> "Sehenswürdigkeit"
GENERIC_GASTRONOMY -> "Gastronomie"
GENERIC_MOBILITY -> "Mobilität"
GENERIC_INFRASTRUCTURE -> "Infrastruktur"
}
}
/** /**
* Ein Explorer-POI aus Overpass/OpenStreetMap. * Ein Explorer-POI aus Overpass/OpenStreetMap.
* *
@@ -18,6 +99,8 @@ import kotlin.math.*
* - osmId / osmType für Attribution/Detail-Link * - osmId / osmType für Attribution/Detail-Link
* - rawTags für das POI-Detail-Sheet * - rawTags für das POI-Detail-Sheet
* - category aus [PoiCategory] * - category aus [PoiCategory]
*
* v2.5.45 — Subtype-Feld ergänzt (kein Backup-/Persistenzmodell-Einfluss).
*/ */
data class ExplorerPoiResult( data class ExplorerPoiResult(
val osmId: Long, val osmId: Long,
@@ -25,6 +108,7 @@ data class ExplorerPoiResult(
val displayName: String, val displayName: String,
val category: PoiCategory, val category: PoiCategory,
val categoryLabel: String, // lesbarer Label wie "Restaurant", "Museum" … val categoryLabel: String, // lesbarer Label wie "Restaurant", "Museum" …
val subtype: PoiSubtype = PoiSubtype.GENERIC_SIGHTS, // v2.5.45
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val rawTags: Map<String, String> = emptyMap(), val rawTags: Map<String, String> = emptyMap(),
@@ -39,52 +123,33 @@ data class LatLngBbox(
) )
/** /**
* Explorer-Overpass-Client — v2.5.44 (POI Category Fix). * Explorer-Overpass-Client — v2.5.45 (Explorer POI Autoload).
* *
* Rate-Limit-Strategie: * Rate-Limit-Strategie:
* - KEIN automatisches Polling. * - Automatisches Polling auf explizite Nutzerwahl — standardmäßig aktiviert,
* - Abfragen nur auf explizite Nutzer-Aktion ("POIs laden"). * aber kontrolliert:
* • Debounce 1.5 s nach Kartenbewegung
* • Nur wenn POI-Layer aktiv
* • Mindest-Zoom ≥ 12 (kein Auto-Laden bei sehr weitem Ausschnitt)
* • Cache-basiert: neue Anfrage nur bei deutlicher Bewegung (> MOVE_THRESHOLD_M)
* oder Zoom-/Kategorie-Wechsel
* • Max-Radius ≤ 5 km (wie bisher)
* - Manueller Lade-Button als Fallback bleibt erhalten.
* - In-Memory-Cache je Kategorie + BBox-Fingerprint. * - In-Memory-Cache je Kategorie + BBox-Fingerprint.
* - Radius auf ≤ 5 km begrenzt; bei größerem Kartenausschnitt Warnung.
* - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie. * - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie.
* *
* v2.5.44 Bug-Fix: Mapping-Priorität korrigiert. * v2.5.45 — Subtype-Auflösung aus OSM-Tags (kein Persistenzmodell-Einfluss).
* - GASTRONOMY, MOBILITY und INFRASTRUCTURE-Tags werden nun ZUERST geprüft,
* bevor historic=* greift. Damit landen Restaurants, Bushaltestellen etc.
* nicht mehr in der Sehenswürdigkeiten-Kategorie.
* - humanizeTag() gibt für alle Kategorien einen sinnvollen Fallback zurück,
* sodass auch namenlose Einträge (z. B. Parkplätze, Bushaltestellen) nicht
* mehr verworfen werden.
* - Default PoiLayerSettings: alle vier Kategorien standardmäßig aktiv
* (vorher nur Sehenswürdigkeiten = Root Cause des gemeldeten Bugs).
* - Erweiterte Overpass-Filter: ferry_terminal, aeroway=aerodrome,
* amenity=ferry_terminal, amenity=shelter, amenity=bench (optional),
* amenity=drinking_water für Infrastruktur.
* - Logcat-Zusammenfassung nach POI-Laden ("POI-Load: X Sehenswürdigkeiten …").
* *
* OSM-Tags je Kategorie: * OSM-Tags je Kategorie: (unverändert zu v2.5.44)
* *
* SIGHTS: * SIGHTS: tourism=attraction|viewpoint|museum|gallery|artwork|zoo|theme_park,
* tourism = attraction | viewpoint | museum | gallery | artwork | zoo | theme_park * historic=*, amenity=theatre|arts_centre|cinema|place_of_worship
* historic = * (jeder Wert) * GASTRONOMY: amenity=restaurant|cafe|bar|pub|fast_food|ice_cream|biergarten|food_court
* amenity = theatre | arts_centre | cinema | place_of_worship * MOBILITY: amenity=parking|bicycle_parking|fuel|charging_station|bus_station|ferry_terminal,
* * highway=bus_stop, railway=station|halt|tram_stop,
* GASTRONOMY: * public_transport=station|platform, aeroway=aerodrome
* amenity = restaurant | cafe | bar | pub | fast_food | ice_cream | * INFRASTRUCTURE: amenity=toilets|hospital|clinic|pharmacy|bank|atm|post_office|
* biergarten | food_court * police|fire_station|library|drinking_water|shelter
*
* MOBILITY:
* amenity = parking | bicycle_parking | fuel | charging_station |
* bus_station | ferry_terminal
* highway = bus_stop
* railway = station | halt | tram_stop
* public_transport = station | platform
* aeroway = aerodrome
*
* INFRASTRUCTURE:
* amenity = toilets | hospital | clinic | pharmacy | bank | atm |
* post_office | police | fire_station | library |
* drinking_water | shelter
* *
* Attribution: POI-Daten © OpenStreetMap-Mitwirkende, ODbL. * Attribution: POI-Daten © OpenStreetMap-Mitwirkende, ODbL.
* Overpass API: overpass-api.de * Overpass API: overpass-api.de
@@ -95,12 +160,49 @@ object ExplorerPoiClient {
private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter" private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter"
private const val MAX_RADIUS_M = 5000 private const val MAX_RADIUS_M = 5000
// v2.5.46 — Auto-Load-Parameter (erhöhte Schwellen für weniger Flackern)
/** Minimaler Zoom für automatisches Laden (unter diesem Zoom: kein Auto-Load). */
const val AUTO_LOAD_MIN_ZOOM = 12.0
/** Mindest-Kartenbewegung in Metern, bevor ein neuer Auto-Load ausgelöst wird.
* v2.5.46: erhöht von 300 m auf 600 m, um überflüssige Nachlades bei kleinen
* Kamerabewegungen (Kippen, Heading-Änderungen, kleiner Zoom) zu vermeiden. */
const val MOVE_THRESHOLD_M = 600.0
// In-Memory-Cache: key = "kategorie|bboxFingerprint" // In-Memory-Cache: key = "kategorie|bboxFingerprint"
private val cache = mutableMapOf<String, List<ExplorerPoiResult>>() private val cache = mutableMapOf<String, List<ExplorerPoiResult>>()
/** Cache leeren — z. B. beim Verlassen des Explorers. */ /** Cache leeren — z. B. beim Verlassen des Explorers oder bei Kategorie-Wechsel. */
fun clearCache() = cache.clear() fun clearCache() = cache.clear()
/**
* Prüft ob ein Auto-Load sinnvoll ist.
* Gibt true zurück wenn:
* - Mindestens ein POI-Layer aktiv ist
* - Zoom ≥ AUTO_LOAD_MIN_ZOOM
* - Karte hat sich um > MOVE_THRESHOLD_M vom letzten Auto-Load-Zentrum entfernt
* ODER Zoom hat sich um > 0.8 verändert ODER Kategorien haben sich geändert.
*/
fun shouldAutoLoad(
zoom: Double,
centerLat: Double,
centerLon: Double,
lastAutoLat: Double?,
lastAutoLon: Double?,
lastAutoZoom: Double?,
lastAutoCategories: Set<PoiCategory>?,
activeCategories: Set<PoiCategory>,
): Boolean {
if (activeCategories.isEmpty()) return false
if (zoom < AUTO_LOAD_MIN_ZOOM) return false
if (lastAutoLat == null || lastAutoLon == null || lastAutoZoom == null || lastAutoCategories == null) return true
if (activeCategories != lastAutoCategories) return true
val moved = haversineM(centerLat, centerLon, lastAutoLat, lastAutoLon)
// v2.5.46: Zoom-Delta-Schwelle erhöht auf 1.2, damit minimales Reinzoomen
// (z. B. Pitch-Änderung oder sehr kleiner Zoom) keinen Reload auslöst.
val zoomDelta = kotlin.math.abs(zoom - lastAutoZoom)
return moved > MOVE_THRESHOLD_M || zoomDelta > 1.2
}
/** /**
* Lädt POIs für [categories] im Kartenausschnitt [bbox]. * Lädt POIs für [categories] im Kartenausschnitt [bbox].
* *
@@ -212,7 +314,6 @@ object ExplorerPoiClient {
): String { ): String {
val around = "around:$radiusM,$lat,$lon" val around = "around:$radiusM,$lat,$lon"
val filters = categoryFilters(category) val filters = categoryFilters(category)
// Jeder Filter wird als node/way/relation-Union gebaut
val unionParts = filters.joinToString("\n") { (key, value) -> val unionParts = filters.joinToString("\n") { (key, value) ->
if (value == "*") { if (value == "*") {
""" node[$key]($around); """ node[$key]($around);
@@ -231,14 +332,6 @@ $unionParts
out center 100;""".trimIndent() out center 100;""".trimIndent()
} }
/**
* OSM-Tag-Filter je Kategorie. Wert "*" bedeutet: jeder Wert.
*
* v2.5.44 Fix: Erweitert um ferry_terminal, tram_stop, aerodrome,
* drinking_water, shelter. Reihenfolge innerhalb SIGHTS unverändert
* (historic bleibt als letzter breitester Filter — gibt aber keine
* Kollision, da jede Kategorie eine eigene Overpass-Anfrage hat).
*/
private fun categoryFilters(cat: PoiCategory): List<Pair<String, String>> = when (cat) { private fun categoryFilters(cat: PoiCategory): List<Pair<String, String>> = when (cat) {
PoiCategory.SIGHTS -> listOf( PoiCategory.SIGHTS -> listOf(
"tourism" to "attraction", "tourism" to "attraction",
@@ -302,14 +395,14 @@ out center 100;""".trimIndent()
val root = JSONObject(json) val root = JSONObject(json)
val elements = root.optJSONArray("elements") ?: return emptyList() val elements = root.optJSONArray("elements") ?: return emptyList()
val out = ArrayList<ExplorerPoiResult>(elements.length()) val out = ArrayList<ExplorerPoiResult>(elements.length())
val seen = HashSet<Long>() // Doppelmarker-Schutz via osmId val seen = HashSet<Long>()
for (i in 0 until elements.length()) { for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue val el = elements.optJSONObject(i) ?: continue
val osmId = el.optLong("id", -1L) val osmId = el.optLong("id", -1L)
val osmType = el.optString("type", "node") val osmType = el.optString("type", "node")
if (osmId < 0) continue if (osmId < 0) continue
if (!seen.add(osmId)) continue // Duplikat if (!seen.add(osmId)) continue
val lat = el.optDouble("lat", Double.NaN).let { val lat = el.optDouble("lat", Double.NaN).let {
if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) ?: Double.NaN if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) ?: Double.NaN
@@ -330,18 +423,13 @@ out center 100;""".trimIndent()
} }
} }
// v2.5.44 Fix: Kategorie-Mapping-Priorität korrigiert.
// resolveCategoryLabel() prüft zuerst spezifische Nicht-SIGHTS-Tags
// (amenity=restaurant usw.) BEVOR historic=* greift. Da jede
// Kategorie eine eigene Anfrage abschickt, ist das Mapping hier
// nur für den categoryLabel relevant — die category selbst stammt
// immer aus dem Funktionsparameter (korrekt).
val catLabel = resolveCategoryLabel(tagsMap, category) val catLabel = resolveCategoryLabel(tagsMap, category)
val subtype = resolveSubtype(tagsMap, category) // v2.5.45
val name = tagsMap["name"]?.takeIf { it.isNotBlank() } val name = tagsMap["name"]?.takeIf { it.isNotBlank() }
?: tagsMap["ref"]?.takeIf { it.isNotBlank() } ?: tagsMap["ref"]?.takeIf { it.isNotBlank() }
?: humanizeTag(tagsMap, category) ?: humanizeTag(tagsMap, category)
?: continue // namenloser POI ohne Fallback → überspringen ?: continue
out.add( out.add(
ExplorerPoiResult( ExplorerPoiResult(
@@ -350,6 +438,7 @@ out center 100;""".trimIndent()
displayName = name, displayName = name,
category = category, category = category,
categoryLabel = catLabel, categoryLabel = catLabel,
subtype = subtype,
latitude = lat, latitude = lat,
longitude = lon, longitude = lon,
rawTags = tagsMap, rawTags = tagsMap,
@@ -360,16 +449,56 @@ out center 100;""".trimIndent()
} }
/** /**
* Menschenlesbarer Kategorie-Label für den POI (z. B. "Restaurant", "Museum"). * v2.5.45 — Subtype aus OSM-Tags ableiten (In-Memory only, kein Backup-Einfluss).
*
* v2.5.44 Fix: Reihenfolge der Prüfungen in SIGHTS korrigiert.
* Gastronomie- und Mobilitäts-spezifische amenity-Werte (restaurant, cafe,
* parking usw.) werden ZUERST auf GASTRONOMY/MOBILITY/INFRASTRUCTURE
* gemappt und kommen nie als Sehenswürdigkeit durch. Da jedoch jede
* Kategorie eine separate Overpass-Anfrage hat, trifft dies in der Praxis
* nur Elemente, die mehrere Tag-Kombinationen tragen (z. B. restaurant +
* tourism=attraction). Für diese gilt: spezifisches Tag gewinnt.
*/ */
private fun resolveSubtype(tags: Map<String, String>, cat: PoiCategory): PoiSubtype {
val amenity = tags["amenity"]
val tourism = tags["tourism"]
val historic = tags["historic"]
val railway = tags["railway"]
val highway = tags["highway"]
return when (cat) {
PoiCategory.SIGHTS -> when {
historic == "castle" || historic == "palace" || historic == "fort" -> PoiSubtype.CASTLE
historic == "ruins" || historic == "archaeological_site" -> PoiSubtype.RUIN
historic == "monastery" -> PoiSubtype.MONASTERY
historic == "church" -> PoiSubtype.CHURCH
historic == "monument" || historic == "memorial" -> PoiSubtype.MONUMENT
historic != null -> PoiSubtype.HISTORIC_OTHER
amenity == "place_of_worship" -> PoiSubtype.CHURCH
tourism == "museum" -> PoiSubtype.MUSEUM
tourism == "viewpoint" -> PoiSubtype.VIEWPOINT
tourism == "gallery" || tourism == "artwork" -> PoiSubtype.GALLERY
tourism == "zoo" || tourism == "theme_park" -> PoiSubtype.ZOO
amenity == "theatre" || amenity == "cinema" || amenity == "arts_centre" -> PoiSubtype.THEATRE
tourism == "attraction" -> PoiSubtype.ATTRACTION
else -> PoiSubtype.GENERIC_SIGHTS
}
PoiCategory.GASTRONOMY -> when (amenity) {
"restaurant" -> PoiSubtype.RESTAURANT
"cafe" -> PoiSubtype.CAFE
"bar", "pub", "biergarten" -> PoiSubtype.BAR
"fast_food", "food_court", "ice_cream" -> PoiSubtype.FAST_FOOD
else -> PoiSubtype.GENERIC_GASTRONOMY
}
PoiCategory.MOBILITY -> when {
amenity == "parking" || amenity == "bicycle_parking" -> PoiSubtype.PARKING
highway == "bus_stop" || amenity == "bus_station" -> PoiSubtype.BUS_STOP
railway == "station" || railway == "halt" -> PoiSubtype.TRAIN_STATION
railway == "tram_stop" -> PoiSubtype.TRAM_STOP
amenity == "fuel" || amenity == "charging_station" -> PoiSubtype.FUEL
else -> PoiSubtype.GENERIC_MOBILITY
}
PoiCategory.INFRASTRUCTURE -> when (amenity) {
"pharmacy" -> PoiSubtype.PHARMACY
"hospital", "clinic" -> PoiSubtype.HOSPITAL
"toilets" -> PoiSubtype.TOILETS
"drinking_water" -> PoiSubtype.DRINKING_WATER
else -> PoiSubtype.GENERIC_INFRASTRUCTURE
}
}
}
private fun resolveCategoryLabel(tags: Map<String, String>, cat: PoiCategory): String { private fun resolveCategoryLabel(tags: Map<String, String>, cat: PoiCategory): String {
val amenity = tags["amenity"] val amenity = tags["amenity"]
val tourism = tags["tourism"] val tourism = tags["tourism"]
@@ -379,9 +508,6 @@ out center 100;""".trimIndent()
val publicTransport = tags["public_transport"] val publicTransport = tags["public_transport"]
val aeroway = tags["aeroway"] val aeroway = tags["aeroway"]
// v2.5.44 Fix: Gastronomie/Mobilität/Infrastruktur haben Vorrang,
// auch wenn ein Element gleichzeitig tourism=* oder historic=* trägt.
// Prüfe zuerst die spezifischen Nicht-SIGHTS-Tags.
if (cat != PoiCategory.SIGHTS) { if (cat != PoiCategory.SIGHTS) {
when (cat) { when (cat) {
PoiCategory.GASTRONOMY -> return when (amenity) { PoiCategory.GASTRONOMY -> return when (amenity) {
@@ -446,41 +572,25 @@ out center 100;""".trimIndent()
amenity == "place_of_worship" -> "Gotteshaus" amenity == "place_of_worship" -> "Gotteshaus"
else -> "Sehenswürdigkeit" else -> "Sehenswürdigkeit"
} }
// Fallbacks für den else-Fall (sollten durch obige when-Blöcke nicht erreicht werden)
PoiCategory.GASTRONOMY -> "Gastronomie" PoiCategory.GASTRONOMY -> "Gastronomie"
PoiCategory.MOBILITY -> "Mobilität" PoiCategory.MOBILITY -> "Mobilität"
PoiCategory.INFRASTRUCTURE-> "Infrastruktur" PoiCategory.INFRASTRUCTURE-> "Infrastruktur"
} }
} }
/**
* Fallback-Name für namenlose POIs, wenn kein [name]-Tag vorhanden ist.
*
* v2.5.44 Fix: Gibt nun für alle Kategorien einen sinnvollen Fallback
* zurück (nicht mehr nur für SIGHTS+historic und Non-SIGHTS). Damit
* werden z. B. namenlose Bushaltestellen, Parkplätze und Toiletten
* nicht mehr verworfen — sie erhalten den Kategorie-Label als Anzeigename.
*
* Ausnahme: Für SIGHTS ohne tourism/historic/spez. amenity-Tag wäre
* "Sehenswürdigkeit" als Name wenig aussagekräftig — diese werden
* nur dann behalten, wenn historic gesetzt ist (Denkmal, Monument etc.)
* oder ein eindeutiger tourism-Wert vorliegt.
*/
private fun humanizeTag(tags: Map<String, String>, cat: PoiCategory): String? { private fun humanizeTag(tags: Map<String, String>, cat: PoiCategory): String? {
return when (cat) { return when (cat) {
PoiCategory.SIGHTS -> { PoiCategory.SIGHTS -> {
// Für SIGHTS nur dann Fallback-Name, wenn ein spezifisches Tag vorhanden ist
val label = resolveCategoryLabel(tags, cat) val label = resolveCategoryLabel(tags, cat)
when { when {
tags["historic"] != null -> label // Denkmal, Ruine etc. immer behalten tags["historic"] != null -> label
tags["tourism"] != null -> label // tourism=viewpoint/museum etc. tags["tourism"] != null -> label
tags["amenity"] in setOf( tags["amenity"] in setOf(
"theatre","arts_centre","cinema","place_of_worship" "theatre","arts_centre","cinema","place_of_worship"
) -> label ) -> label
else -> null // zu generisch → überspringen else -> null
} }
} }
// GASTRONOMY, MOBILITY, INFRASTRUCTURE: immer Fallback-Label zurückgeben
PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat) PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat)
PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat) PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat)
PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat) PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat)
@@ -503,6 +613,9 @@ out center 100;""".trimIndent()
"locomotive" -> "Lokomotive (hist.)" "locomotive" -> "Lokomotive (hist.)"
"aircraft" -> "Flugzeug (hist.)" "aircraft" -> "Flugzeug (hist.)"
"ship" -> "Schiff (hist.)" "ship" -> "Schiff (hist.)"
"monastery" -> "Kloster"
"palace" -> "Schloss/Palast"
"fort" -> "Festung"
else -> "Historisch" else -> "Historisch"
} }
@@ -1212,6 +1212,8 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap()) val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap()) val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
val input = de.waypointaudio.io.BackupExportManager.BackupInput( val input = de.waypointaudio.io.BackupExportManager.BackupInput(
waypoints = _waypoints.value, waypoints = _waypoints.value,
@@ -1221,6 +1223,7 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
musicSettings = musicSettingsMap, musicSettings = musicSettingsMap,
tourRoutes = tourRoutesMap, tourRoutes = tourRoutesMap,
tourCovers = tourCoversMap, tourCovers = tourCoversMap,
trackDrafts = trackDraftsSnapshot,
appVersionName = pkgInfo?.versionName ?: "", appVersionName = pkgInfo?.versionName ?: "",
appVersionCode = versionCode appVersionCode = versionCode
) )
@@ -1366,6 +1369,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
} }
} }
if (trackDrafts) { if (trackDrafts) {
// v2.5.48 FIX: Echte TrackDraft-Objekte importieren statt gpsTracks-Map.
// trackDraftsToAdd enthält bereits neue UUIDs (vergeben im ImportManager).
payload.trackDraftsToAdd.forEach { draft ->
repository.addTrackDraft(draft)
}
// tracksToWrite ist für die Live-GPS-Track-Map (gpsTracksFlow).
// Bei neuem Format leer; bei altem Format auch leer (kein Import).
payload.tracksToWrite.forEach { (tour, points) -> payload.tracksToWrite.forEach { (tour, points) ->
repository.saveGpsTrack(tour, points) repository.saveGpsTrack(tour, points)
} }
@@ -0,0 +1,205 @@
# GPS2Audio v2.5.45 — Explorer POI Autoload — Handoff
## Build-Ergebnis
| Artefakt | Status |
|---|---|
| APK | ✅ BUILD SUCCESSFUL |
| Signing | ✅ APK Signature Scheme v2 verified |
| applicationId | `de.waypointaudio` |
| versionCode | 99 |
| versionName | 2.5.45 |
| APK-Datei | `GPS2Audio_v2_5_45_explorer_poi_autoload_universal_signed.apk` (57 MB) |
| Quelle | `GPS2Audio_v2_5_45_explorer_poi_autoload_source.zip` |
Build-Basis: `GPS2Audio_v2_5_44_poi_category_fix_source.zip`
---
## 1. Automatisches POI-Nachladen
### Verhalten
- **Standardmäßig aktiviert** — kein manueller Start nötig.
- POIs werden nach Kartenbewegung automatisch geladen (1.5 Sekunden Debounce nach `onCameraIdle`).
- Neue Anfrage **nur** wenn:
- Mindest-Zoom ≥ 12.0 (`ExplorerPoiClient.AUTO_LOAD_MIN_ZOOM`)
- Karte hat sich um > 300 m bewegt (`MOVE_THRESHOLD_M`) **oder** Zoom hat sich um > 0.8 geändert **oder** Kategorien haben sich geändert
- POI-Layer aktiv (mindestens eine Kategorie)
- Kein laufendes Load in progress
### Schutzmechanismen
| Schutz | Wert |
|---|---|
| Debounce | 1.5 Sekunden nach `onCameraIdle` |
| Minimaler Zoom | ≥ 12.0 (darunter: keine Auto-Anfrage) |
| Maximaler Radius | 5 000 m (wie bisher, `MAX_RADIUS_M`) |
| Cache-Schutz | Cache per Kategorie + BBox-Fingerprint; neue Anfrage nur bei > 300 m Bewegung oder Zoom-/Kategorie-Wechsel |
| Fehlerverhalten | Bei Fehler: alte POIs bleiben, dezente Snackbar-Meldung; kein White-Screen |
| Parallel-Schutz | `explorerPoiLoading`-Flag; laufende Auto-Load-Jobs werden bei neuem Trigger gecancelt |
| Kategorie-Wechsel | Cache + Cursor werden geleert, nächster Idle lädt frisch |
### Manueller Fallback
Der manuelle Reload-Button (`↻`) ist **erhalten** und erzwingt immer einen frischen Load (Cache + Cursor werden geleert).
### Statusanzeige (POI-Bar, BottomCenter)
| Zustand | Anzeige |
|---|---|
| Laden läuft | Spinner + „POIs werden aktualisiert…" |
| Auto-Load aktiv, Ergebnis vorhanden | „POIs: 42" |
| Auto-Load aktiv, Zoom zu klein | „Hereinzoomen für Auto-Load" |
| Auto-Load aktiv, keine Ergebnisse geladen | „POIs: Auto-Laden aktiv" |
| Auto-Load deaktiviert | „POIs: manuell laden" |
### Toggle im Layer-Sheet
Im Ebenen-Panel (Layers-FAB) im POI-Abschnitt:
**„POIs automatisch nachladen"** — Switch, Standard: EIN
Beschriftungshinweis: „Auto-Laden: POIs werden beim Kartenverschieben aktualisiert (ab Zoom 12)"
---
## 2. POI-Untertypen (Subtypes)
### Architektur
Neues Enum `PoiSubtype` in `ExplorerPoiClient.kt`**nur In-Memory/Runtime, kein Persistenz-/Backup-Einfluss**.
`ExplorerPoiResult` erhält ein neues Feld `subtype: PoiSubtype` (Default: `GENERIC_*`).
### Enthaltene Untertypen
| Kategorie | Subtype | OSM-Tags | Piktogramm |
|---|---|---|---|
| SIGHTS | CASTLE | historic=castle/palace/fort | Zinnenkranz + Tor |
| SIGHTS | RUIN | historic=ruins/archaeological_site | Gebrochene Mauer |
| SIGHTS | CHURCH | amenity=place_of_worship, historic=church | Kreuz |
| SIGHTS | MONASTERY | historic=monastery | Kleines Kreuz + Bogen |
| SIGHTS | MUSEUM | tourism=museum | Giebelhaus |
| SIGHTS | VIEWPOINT | tourism=viewpoint | Fernglas |
| SIGHTS | MONUMENT | historic=monument/memorial | Säule |
| SIGHTS | GALLERY | tourism=gallery/artwork | Bilderrahmen |
| SIGHTS | THEATRE | amenity=theatre/cinema/arts_centre | Drama-Bögen |
| SIGHTS | ZOO | tourism=zoo/theme_park | Pfotenabdruck |
| SIGHTS | ATTRACTION | tourism=attraction | Monument-Nadel |
| SIGHTS | HISTORIC_OTHER | historic=* (sonstige) | Säule |
| GASTRONOMY | RESTAURANT | amenity=restaurant | Messer + Gabel |
| GASTRONOMY | CAFE | amenity=cafe | Tasse + Dampf |
| GASTRONOMY | BAR | amenity=bar/pub/biergarten | Bierglas |
| GASTRONOMY | FAST_FOOD | amenity=fast_food/food_court/ice_cream | Burger-Silhouette |
| MOBILITY | PARKING | amenity=parking | „P"-Piktogramm |
| MOBILITY | BUS_STOP | highway=bus_stop, amenity=bus_station | Bus-Silhouette |
| MOBILITY | TRAIN_STATION | railway=station/halt | Zug |
| MOBILITY | TRAM_STOP | railway=tram_stop | Straßenbahn + Stromabnehmer |
| MOBILITY | FUEL | amenity=fuel/charging_station | Zapfsäule |
| INFRASTRUCTURE | PHARMACY | amenity=pharmacy | Apotheken-Kreuz |
| INFRASTRUCTURE | HOSPITAL | amenity=hospital/clinic | H-Symbol |
| INFRASTRUCTURE | TOILETS | amenity=toilets | WC-Piktogramm |
| INFRASTRUCTURE | DRINKING_WATER | amenity=drinking_water | Wassertropfen |
Alle nicht spezifisch gemappten POIs erhalten ihr Kategorie-Fallback-Icon (wie v2.5.44).
### Marker-Design
- **Hauptkategorie-Farbe** bleibt erhalten (Teal/Amber/Dunkelblau/Dunkelgrün).
- **Hintergrundform** bleibt per Kategorie (Stern/Kreis/Abger. Rechteck/Kreis mit Rand).
- **Inneres Piktogramm** variiert per Subtype (Canvas-Zeichnung, wie v2.5.43-Stil).
- **Sentinel-Pixel** berücksichtigt Kategorie + Subtype-Ordinal, verhindert MapLibre-Cache-Kollisionen.
### Detail-Sheet
Zeigt jetzt `poi.subtype.displayLabel` statt `poi.categoryLabel`, z. B. „Burg/Schloss" statt „Sehenswürdigkeit".
### Legende
- Kompakt-Ansicht: Farbpunkte wie bisher.
- Ausgeklappt: Hinweis **„Symbole zeigen Untertypen"** (neu in v2.5.45).
---
## 3. Explorer-UX-Aufräumungen
### Vorgenommen
| Bereich | Änderung |
|---|---|
| POI-Statusbar | Redesign von reinem Lade-Button zu kompakter Status+Reload-Bar (BottomCenter) |
| POI-Bar Text | Kontextabhängiger Statustext (Auto-Laden aktiv / Zoom-Hinweis / Fehlerzustand) |
| Layer-Sheet | Auto-Load-Toggle im POI-Abschnitt ergänzt; Beschreibungshinweis |
| Legende | Hinweis „Symbole zeigen Untertypen" im aufgeklappten Zustand |
| Detail-Sheet | Konkreterer Typ-Label (Subtype statt Kategorie) |
### Nicht verändert
- Kein Screen, Dialog oder Sheet entfernt.
- TopBar, FAB-Layout, Attribution, Kompass, Nordausrichtung unverändert.
- Routenplaner/Storyboard-UI nicht umgebaut (wie gefordert).
- Phantom-Track-Fixes, Waypoint-Logik, Follow-Location unverändert.
---
## 4. Was NICHT verändert wurde
| Bereich | Status |
|---|---|
| Storyboard/Routenplaner-Datenmodell | ❌ Nicht angefasst |
| Backup/Import/Export (ZIP, GPX) | ❌ Nicht angefasst |
| Playback/Multi-Clip/Player | ❌ Nicht angefasst |
| Lizenzsystem (ECDSA-Validierung, Storage) | ❌ Nicht angefasst |
| Waypoints/Touren/Tracks | ❌ Nicht angefasst |
| GPS-Aufzeichnung | ❌ Nicht angefasst |
| PoiCategory-Enum | ❌ Nicht angefasst (rückwärtskompatibel) |
| PoiLayerSettings-Persistenz | ❌ Nicht angefasst (keine neuen Keys) |
| MapStyleSettings | ❌ Nicht angefasst |
| applicationId | `de.waypointaudio` — unverändert |
| Keystore-Kompatibilität | Gleicher Keystore wie v2.5.44 |
| Canvas-Piktogramm-System (v2.5.43) | Erhalten und erweitert |
---
## 5. Geänderte Dateien
| Datei | Art der Änderung |
|---|---|
| `app/build.gradle.kts` | versionCode 98→99, versionName 2.5.44→2.5.45 |
| `util/ExplorerPoiClient.kt` | Neues `PoiSubtype`-Enum; `ExplorerPoiResult.subtype`; `resolveSubtype()`; `shouldAutoLoad()`-Funktion |
| `ui/ExplorerScreenMapLibre.kt` | Auto-Load-State + Debounce-Logik in cameraIdleListener; Subtype-Icons in Marker-Drawing; Detail-Sheet Subtype-Label; neue POI-Statusbar; MapLayerFab Auto-Load-Callbacks |
| `ui/MapLayerPanel.kt` | `poiAutoLoadEnabled`/`onPoiAutoLoadChange`-Parameter; Toggle-Row im POI-Abschnitt |
---
## 6. Smoke-Tests (manuell simuliert / Compile-Test)
- ✅ Build: `assembleRelease` erfolgreich (43 tasks executed)
- ✅ APK-Signing: v2 scheme verifiziert
- ✅ versionCode = 99, versionName = 2.5.45, applicationId = de.waypointaudio
- ✅ Keine Kompilier-Fehler (nur Pre-existing deprecation-Warnings aus älteren Dateien)
-`PoiSubtype` mit allen 25 Werten kompiliert
- ✅ Auto-Load-Logik: `shouldAutoLoad()` in `ExplorerPoiClient`, verwendet in `cameraIdleListener`
- ✅ Subtype-Icons: `buildExplorerPoiIcon(category, subtype)` mit When-Exhaustive-Abdeckung
- ⚠️ Duplicate-Branch-Warning in `ExplorerPoiClient.kt` (Z. 614: `"fort"` doppelt in `humanizeHistoric`) — funktional ohne Auswirkung, nur Compiler-Hinweis
---
## 7. Bekannte Grenzen
| Thema | Beschreibung |
|---|---|
| Kein APK-Größen-Split | Universal APK (57 MB), kein ABI-Split — wie gefordert |
| Auto-Load nur online | Kein Offline-Cache; bei Netzwerkfehler bleiben alte POIs erhalten |
| Subtype-Piktogramme | 25 Subtypes; sehr kleine Piktogramme (56×56 px) können auf kleinen Displays unter Details leiden — aber alle klar erkennbar |
| MapLibre Icon-Cache | Sentinel-Pixel-System verhindert Cache-Kollisionen; bei sehr vielen POIs kann es zu Marker-Redraws beim ersten Laden kommen |
| Auto-Load-Debouce | 1.5 s Debounce — bei sehr schnellem Scrolling löst erst das finale Idle aus |
| Storyboard/v2.6.0 | Kein Datenmodell-Umbau in v2.5.45. Vorarbeitung für v2.6.0: `PoiSubtype` als erweiterungsfähiges Enum vorhanden |
| Duplicate `"fort"` | In `humanizeHistoric()`: `"fort"` erscheint zweimal (Zeile 614 Warning). Zweiter Eintrag wird nie erreicht — keine funktionale Auswirkung |
---
## 8. Upgrade-Pfad
- Update von v2.5.44 auf v2.5.45 mit demselben Keystore (`GPS2Audio_release_keystore_v2.jks`) ist als In-Place-Update möglich.
- Alle gespeicherten Einstellungen (POI-Layer, Kartenstil, Follow-Location) bleiben erhalten.
- Keine Datenbank-Migration notwendig.