diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aea259f..2d4aa80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,8 +157,8 @@ android { // • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, // PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing, // Tour-/Waypoint-Kopien, Hintergrundwiedergabe. - versionCode = 98 - versionName = "2.5.44" + versionCode = 99 + versionName = "2.5.45" } val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE") diff --git a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt index 02e0cce..32167a8 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt @@ -121,8 +121,11 @@ 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 @@ -324,6 +327,15 @@ fun ExplorerScreenMapLibre( var currentMapZoom by remember { mutableStateOf(13.0) } val snackbarHostState = remember { SnackbarHostState() } + // v2.5.45 — Auto-POI-Load State + var autoPoiLoadEnabled by remember { mutableStateOf(true) } // standardmäßig aktiviert + var poiAutoLoadJob by remember { mutableStateOf(null) } + var lastAutoLoadLat by remember { mutableStateOf(null) } + var lastAutoLoadLon by remember { mutableStateOf(null) } + var lastAutoLoadZoom by remember { mutableStateOf(null) } + var lastAutoLoadCats by remember { mutableStateOf?>(null) } + var autoPoiStatus by remember { mutableStateOf(null) } // kurzer Status-Text + var lastLat by remember { mutableStateOf(null) } var lastLng by remember { mutableStateOf(null) } var lastAccuracyM by remember { mutableStateOf(null) } @@ -455,10 +467,14 @@ fun ExplorerScreenMapLibre( } } // 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. @@ -498,16 +514,17 @@ fun ExplorerScreenMapLibre( val iconFactory = IconFactory.getInstance(context) for ((cat, pois) in explorerPoiResults) { for (poi in pois) { + // v2.5.45 — Subtype-Icon: Piktogramm variiert nach Untertyp, Farbe bleibt Kategorie val iconBitmap = if (showPoiNames) { - buildExplorerPoiIconWithLabel(cat, poi.displayName) + buildExplorerPoiIconWithLabel(cat, poi.subtype, poi.displayName) } else { - buildExplorerPoiIcon(cat) + buildExplorerPoiIcon(cat, poi.subtype) } val icon = iconFactory.fromBitmap(iconBitmap) val mo = MarkerOptions() .position(LatLng(poi.latitude, poi.longitude)) .title(poi.displayName) - .snippet(poi.categoryLabel) + .snippet(poi.subtype.displayLabel) // v2.5.45: Subtype-Label statt categoryLabel .icon(icon) val marker = runCatching { ml.addMarker(mo) }.getOrNull() ?: continue explorerPoiMarkers.add(marker) @@ -2131,8 +2148,65 @@ fun ExplorerScreenMapLibre( override fun onMoveEnd(detector: org.maplibre.android.gestures.MoveGestureDetector) {} }) // v2.5.41 — Zoom-Tracking für zoom-abhängige POI-Label-Anzeige + // v2.5.45 — Auto-POI-Load: debounced nach Kamera-Idle ml.addOnCameraIdleListener { currentMapZoom = ml.cameraPosition.zoom + // Auto-Load nur wenn aktiviert, POI-Layer an, Zoom genügend + if (autoPoiLoadEnabled && !explorerPoiLoading) { + val cp = ml.cameraPosition + val zoom = cp.zoom + val cLat = cp.target?.latitude ?: return@addOnCameraIdleListener + val cLon = cp.target?.longitude ?: return@addOnCameraIdleListener + val activeCats = buildSet { + if (layerState.poiLayers.showSights) add(de.waypointaudio.data.PoiCategory.SIGHTS) + if (layerState.poiLayers.showGastronomy) add(de.waypointaudio.data.PoiCategory.GASTRONOMY) + if (layerState.poiLayers.showMobility) add(de.waypointaudio.data.PoiCategory.MOBILITY) + if (layerState.poiLayers.showInfrastructure) add(de.waypointaudio.data.PoiCategory.INFRASTRUCTURE) + } + if (de.waypointaudio.util.ExplorerPoiClient.shouldAutoLoad( + zoom, cLat, cLon, + lastAutoLoadLat, lastAutoLoadLon, lastAutoLoadZoom, + lastAutoLoadCats, activeCats + ) + ) { + // Debounce: 1.5 s + poiAutoLoadJob?.cancel() + poiAutoLoadJob = scope.launch { + delay(1500L) + 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 "$total POIs" + } catch (e: Exception) { + // Alten Bestand behalten, nur dezente Fehlermeldung + autoPoiStatus = null + explorerPoiSnackbar = "POI-Aktualisierung fehlgeschlagen." + } finally { + explorerPoiLoading = false + } + } + } + } } } mv.onStart() @@ -2274,6 +2348,12 @@ fun ExplorerScreenMapLibre( }, 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) @@ -2320,42 +2400,72 @@ fun ExplorerScreenMapLibre( ) } - // v2.5.40 — Explorer-POI-Lade-Button (mittig unten, über Attribution) - if (layerState.poiLayers.let { - it.showSights || it.showGastronomy || it.showMobility || it.showInfrastructure - }) { + // 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.95f), + 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 = 12.dp, vertical = 6.dp), + 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(14.dp), - strokeWidth = 2.dp, + modifier = Modifier.size(13.dp), + strokeWidth = 1.8.dp, color = MaterialTheme.colorScheme.onSecondaryContainer ) } else { Icon( - Icons.Filled.Explore, + if (autoPoiLoadEnabled) Icons.Filled.Explore else Icons.Filled.Explore, contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSecondaryContainer + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy( + alpha = if (autoPoiLoadEnabled) 1f else 0.55f + ) ) } + // Status-Text + val statusText = when { + explorerPoiLoading -> "POIs werden aktualisiert…" + autoPoiStatus != null -> "POIs: $autoPoiStatus" + autoPoiLoadEnabled && currentMapZoom < de.waypointaudio.util.ExplorerPoiClient.AUTO_LOAD_MIN_ZOOM + -> "Hereinzoomen für Auto-Load" + autoPoiLoadEnabled -> "POIs: Auto-Laden aktiv" + else -> "POIs: manuell laden" + } Text( - text = if (explorerPoiLoading) "POIs werden geladen …" - else "POIs im Kartenbereich laden", + 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, - 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) { val ml = mapLibreMap ?: return@clickable val bounds = ml.projection.visibleRegion.latLngBounds @@ -2371,7 +2481,11 @@ fun ExplorerScreenMapLibre( 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( @@ -2379,17 +2493,21 @@ fun ExplorerScreenMapLibre( bbox = bbox, categories = cats, 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 } explorerPoiResults = results - // v2.5.44 — POI-Zusammenfassung im Snackbar - explorerPoiSnackbar = if (total == 0) { - "Keine POIs in diesem Bereich gefunden." - } else { + 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 { @@ -2446,12 +2564,13 @@ fun ExplorerScreenMapLibre( 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.categoryLabel, + 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) @@ -3506,8 +3625,20 @@ fun MapCompactAttributionBadge(modifier: Modifier = Modifier) { * MOBILITY — Indigo (#3F51B5) + Abgerundetes Quadrat + Bus-Symbol (B) * 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.45 — Subtype-Piktogramme für Sights/Gastro/Mobil/Infra val sizePx = 56 // Kategorie-Farben — dezent, App-Teal-Stil passend @@ -3518,13 +3649,15 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF558B2F.toInt() // Dunkelgrün (Infrastruktur/Info) } - // Sentinel-Byte-Wert je Kategorie (eindeutige Pixel-Markierung für MapLibre-Cache) - val sentinelAlpha = when (category) { - de.waypointaudio.data.PoiCategory.SIGHTS -> 0xFC - de.waypointaudio.data.PoiCategory.GASTRONOMY -> 0xFD - de.waypointaudio.data.PoiCategory.MOBILITY -> 0xFE - de.waypointaudio.data.PoiCategory.INFRASTRUCTURE-> 0xFF + // 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) @@ -3605,101 +3738,269 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B } } - // ── Weißes Piktogramm per Canvas ───────────────────────────────────────── + + // ── Weißes Piktogramm per Canvas (v2.5.45: Subtype-Varianten) ────────────────── paint.color = 0xFFFFFFFF.toInt() paint.style = android.graphics.Paint.Style.FILL paint.strokeWidth = 2.2f - when (category) { - de.waypointaudio.data.PoiCategory.SIGHTS -> { - // Stern-Marker: Stern-Hintergrund ist schon der Rahmen. - // Piktogramm: kleiner innerer Punkt + Strahllinien wie eine Landmarke/Monument-Nadel - // Vertikaler Stab (Monument-Pin) - val stW = 3.5f - val stH = 14f - val stTop = cy - 10f - val stBot = cy + 4f - val stLeft = cx - stW / 2f - canvas.drawRect(stLeft, stTop, stLeft + stW, stBot, paint) - // Kleines Dreieck-Dach oben (Turm/Monument) + 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() + moveTo(cx, stTop - 5f); lineTo(cx - 5f, stTop); lineTo(cx + 5f, stTop); close() } canvas.drawPath(roofPath, paint) - // Sockel/Basis unten canvas.drawRect(cx - 6f, stBot, cx + 6f, stBot + 2.5f, paint) } - de.waypointaudio.data.PoiCategory.GASTRONOMY -> { - // Tasse mit Henkel und Dampf-Linie - val cupLeft = cx - 9f - val cupRight = cx + 9f - val cupTop = cy - 3f - val cupBot = cy + 9f - // Tassenform: Trapez (breiter unten) - val cupPath = android.graphics.Path().apply { - moveTo(cupLeft + 2f, cupTop) - lineTo(cupLeft, cupBot) - lineTo(cupRight, cupBot) - lineTo(cupRight - 2f, cupTop) - close() + // ─── 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.STROKE - paint.strokeWidth = 2.3f + 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) - // Untertasse - paint.strokeWidth = 2f - 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 = 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) - quadTo(cx + 3f, cupTop + 1f, cx + 5f, cupTop - 3f) - } - canvas.drawPath(steamPath, paint) + moveTo(cx - 3f, cupTop - 3f); quadTo(cx - 1f, cupTop - 7f, cx + 1f, cupTop - 3f) + }; canvas.drawPath(steamPath, paint) } - de.waypointaudio.data.PoiCategory.MOBILITY -> { - // Bus-Silhouette: rechteckiger Körper, Räder, Fenster - val busLeft = cx - 10f - val busRight = cx + 10f - val busTop = cy - 8f - val busBot = cy + 6f - // Buskarosserie - paint.style = android.graphics.Paint.Style.STROKE - paint.strokeWidth = 2.2f - val busRect = android.graphics.RectF(busLeft, busTop, busRight, busBot) - canvas.drawRoundRect(busRect, 3f, 3f, paint) - // Frontscheibe (linke Seite) + 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) - // Fenster 1 - val w1 = android.graphics.RectF(busLeft + 4f, busTop + 2f, busLeft + 9f, busTop + 6f) paint.strokeWidth = 1.5f - canvas.drawRoundRect(w1, 1f, 1f, paint) - // Fenster 2 - val w2 = android.graphics.RectF(busLeft + 11f, busTop + 2f, busLeft + 16f, busTop + 6f) - canvas.drawRoundRect(w2, 1f, 1f, paint) - // Rad links + 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) - // Rad rechts canvas.drawCircle(busRight - 5f, busBot + 1f, 3f, paint) } - de.waypointaudio.data.PoiCategory.INFRASTRUCTURE -> { - // Info-"i"-Piktogramm: großer Punkt oben, vertikaler Stab unten + 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 - // Punkt oben canvas.drawCircle(cx, cy - 8f, 3.5f, paint) - // Stab unten — leicht breiter, abgerundet val stW2 = 4.5f - val rect2 = android.graphics.RectF(cx - stW2 / 2f, cy - 2f, cx + stW2 / 2f, cy + 9f) - canvas.drawRoundRect(rect2, 2f, 2f, paint) - // Basis-Strich + 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) } } @@ -3712,6 +4013,7 @@ private fun buildExplorerPoiIcon(category: de.waypointaudio.data.PoiCategory): B private fun buildExplorerPoiIconWithLabel( category: de.waypointaudio.data.PoiCategory, + subtype: de.waypointaudio.util.PoiSubtype = de.waypointaudio.util.PoiSubtype.GENERIC_SIGHTS, name: String ): Bitmap { val iconSize = 56 // gleiche Größe wie buildExplorerPoiIcon @@ -3736,7 +4038,7 @@ private fun buildExplorerPoiIconWithLabel( val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) // Zeichne Kategorie-Icon zentriert oben - val iconBmp = buildExplorerPoiIcon(category) + val iconBmp = buildExplorerPoiIcon(category, subtype) val iconLeft = (totalW - iconSize) / 2f canvas.drawBitmap(iconBmp, iconLeft, 0f, null) iconBmp.recycle() @@ -3844,11 +4146,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( "antippen zum Einklappen", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), - modifier = Modifier.padding(top = 1.dp) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f), + modifier = Modifier.padding(top = 0.dp) ) } } diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt index 54c1d88..2143754 100644 --- a/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapLayerPanel.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.GpsFixed import androidx.compose.material.icons.filled.Info @@ -105,6 +106,9 @@ fun MapLayerFab( onPerspectiveTiltChange: (Int) -> Unit = {}, onShowRoutingInfo: () -> Unit = {}, onShowRoutePlanner: () -> Unit = {}, + // v2.5.45 — Auto-POI-Load-Toggle + poiAutoLoadEnabled: Boolean = true, + onPoiAutoLoadChange: (Boolean) -> Unit = {}, modifier: Modifier = Modifier ) { 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) + ) } } diff --git a/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt b/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt index 26b29bf..037a08b 100644 --- a/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt +++ b/app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt @@ -11,6 +11,87 @@ import java.net.URL import java.net.URLEncoder 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. * @@ -18,6 +99,8 @@ import kotlin.math.* * - osmId / osmType für Attribution/Detail-Link * - rawTags für das POI-Detail-Sheet * - category aus [PoiCategory] + * + * v2.5.45 — Subtype-Feld ergänzt (kein Backup-/Persistenzmodell-Einfluss). */ data class ExplorerPoiResult( val osmId: Long, @@ -25,6 +108,7 @@ data class ExplorerPoiResult( val displayName: String, val category: PoiCategory, val categoryLabel: String, // lesbarer Label wie "Restaurant", "Museum" … + val subtype: PoiSubtype = PoiSubtype.GENERIC_SIGHTS, // v2.5.45 val latitude: Double, val longitude: Double, val rawTags: Map = 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: - * - KEIN automatisches Polling. - * - Abfragen nur auf explizite Nutzer-Aktion ("POIs laden"). + * - Automatisches Polling auf explizite Nutzerwahl — standardmäßig aktiviert, + * 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. - * - Radius auf ≤ 5 km begrenzt; bei größerem Kartenausschnitt Warnung. * - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie. * - * v2.5.44 Bug-Fix: Mapping-Priorität korrigiert. - * - 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 …"). + * v2.5.45 — Subtype-Auflösung aus OSM-Tags (kein Persistenzmodell-Einfluss). * - * OSM-Tags je Kategorie: + * OSM-Tags je Kategorie: (unverändert zu v2.5.44) * - * SIGHTS: - * tourism = attraction | viewpoint | museum | gallery | artwork | zoo | theme_park - * historic = * (jeder Wert) - * amenity = theatre | arts_centre | cinema | place_of_worship - * - * GASTRONOMY: - * amenity = restaurant | cafe | bar | pub | fast_food | ice_cream | - * biergarten | food_court - * - * 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 + * SIGHTS: tourism=attraction|viewpoint|museum|gallery|artwork|zoo|theme_park, + * historic=*, amenity=theatre|arts_centre|cinema|place_of_worship + * GASTRONOMY: amenity=restaurant|cafe|bar|pub|fast_food|ice_cream|biergarten|food_court + * 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. * Overpass API: overpass-api.de @@ -95,12 +160,45 @@ object ExplorerPoiClient { private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter" private const val MAX_RADIUS_M = 5000 + // v2.5.45 — Auto-Load-Parameter + /** 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. */ + const val MOVE_THRESHOLD_M = 300.0 + // In-Memory-Cache: key = "kategorie|bboxFingerprint" private val cache = mutableMapOf>() - /** Cache leeren — z. B. beim Verlassen des Explorers. */ + /** Cache leeren — z. B. beim Verlassen des Explorers oder bei Kategorie-Wechsel. */ 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?, + activeCategories: Set, + ): 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) + val zoomDelta = kotlin.math.abs(zoom - lastAutoZoom) + return moved > MOVE_THRESHOLD_M || zoomDelta > 0.8 + } + /** * Lädt POIs für [categories] im Kartenausschnitt [bbox]. * @@ -212,7 +310,6 @@ object ExplorerPoiClient { ): String { val around = "around:$radiusM,$lat,$lon" val filters = categoryFilters(category) - // Jeder Filter wird als node/way/relation-Union gebaut val unionParts = filters.joinToString("\n") { (key, value) -> if (value == "*") { """ node[$key]($around); @@ -231,14 +328,6 @@ $unionParts 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> = when (cat) { PoiCategory.SIGHTS -> listOf( "tourism" to "attraction", @@ -302,14 +391,14 @@ out center 100;""".trimIndent() val root = JSONObject(json) val elements = root.optJSONArray("elements") ?: return emptyList() val out = ArrayList(elements.length()) - val seen = HashSet() // Doppelmarker-Schutz via osmId + val seen = HashSet() for (i in 0 until elements.length()) { val el = elements.optJSONObject(i) ?: continue val osmId = el.optLong("id", -1L) val osmType = el.optString("type", "node") if (osmId < 0) continue - if (!seen.add(osmId)) continue // Duplikat + if (!seen.add(osmId)) continue val lat = el.optDouble("lat", Double.NaN).let { if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) ?: Double.NaN @@ -330,18 +419,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 subtype = resolveSubtype(tagsMap, category) // v2.5.45 val name = tagsMap["name"]?.takeIf { it.isNotBlank() } ?: tagsMap["ref"]?.takeIf { it.isNotBlank() } ?: humanizeTag(tagsMap, category) - ?: continue // namenloser POI ohne Fallback → überspringen + ?: continue out.add( ExplorerPoiResult( @@ -350,6 +434,7 @@ out center 100;""".trimIndent() displayName = name, category = category, categoryLabel = catLabel, + subtype = subtype, latitude = lat, longitude = lon, rawTags = tagsMap, @@ -360,16 +445,56 @@ out center 100;""".trimIndent() } /** - * Menschenlesbarer Kategorie-Label für den POI (z. B. "Restaurant", "Museum"). - * - * 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. + * v2.5.45 — Subtype aus OSM-Tags ableiten (In-Memory only, kein Backup-Einfluss). */ + private fun resolveSubtype(tags: Map, 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, cat: PoiCategory): String { val amenity = tags["amenity"] val tourism = tags["tourism"] @@ -379,9 +504,6 @@ out center 100;""".trimIndent() val publicTransport = tags["public_transport"] 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) { when (cat) { PoiCategory.GASTRONOMY -> return when (amenity) { @@ -446,41 +568,25 @@ out center 100;""".trimIndent() amenity == "place_of_worship" -> "Gotteshaus" else -> "Sehenswürdigkeit" } - // Fallbacks für den else-Fall (sollten durch obige when-Blöcke nicht erreicht werden) PoiCategory.GASTRONOMY -> "Gastronomie" PoiCategory.MOBILITY -> "Mobilität" 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, cat: PoiCategory): String? { return when (cat) { PoiCategory.SIGHTS -> { - // Für SIGHTS nur dann Fallback-Name, wenn ein spezifisches Tag vorhanden ist val label = resolveCategoryLabel(tags, cat) when { - tags["historic"] != null -> label // Denkmal, Ruine etc. immer behalten - tags["tourism"] != null -> label // tourism=viewpoint/museum etc. + tags["historic"] != null -> label + tags["tourism"] != null -> label tags["amenity"] in setOf( "theatre","arts_centre","cinema","place_of_worship" ) -> label - else -> null // zu generisch → überspringen + else -> null } } - // GASTRONOMY, MOBILITY, INFRASTRUCTURE: immer Fallback-Label zurückgeben PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat) PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat) PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat) @@ -503,6 +609,9 @@ out center 100;""".trimIndent() "locomotive" -> "Lokomotive (hist.)" "aircraft" -> "Flugzeug (hist.)" "ship" -> "Schiff (hist.)" + "monastery" -> "Kloster" + "palace" -> "Schloss/Palast" + "fort" -> "Festung" else -> "Historisch" } diff --git a/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_handoff.md b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_handoff.md new file mode 100644 index 0000000..1e2ddc9 --- /dev/null +++ b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_handoff.md @@ -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. diff --git a/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_source.zip b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_source.zip new file mode 100644 index 0000000..a751776 Binary files /dev/null and b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_source.zip differ diff --git a/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_universal_signed.apk b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_universal_signed.apk new file mode 100644 index 0000000..341623a Binary files /dev/null and b/releases/v2.5.45/GPS2Audio_v2_5_45_explorer_poi_autoload_universal_signed.apk differ