mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat: render road event (kind 1315/1316) location on an OSM map
Replace the reverse-geocoded "📍 City" line on the Roadstr road event report and confirmation cards with an osmdroid OpenStreetMap preview pinned at the event's coordinates. - Add osmdroid-android 6.1.20 (Apache-2.0) to the version catalog and the amethyst module. - New LocationPreviewMap composable: an AndroidView-wrapped MapView with MAPNIK tiles, a single marker, lifecycle-aware onResume/onPause/onDetach, and nested-scroll-friendly touch handling so panning the map doesn't fight the feed scroll. Sets the OSM User-Agent to the package name (required or OSM returns 403). - RoadEvent cards resolve a point from the explicit lat/lon tags, falling back to the center of the finest published geohash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
This commit is contained in:
@@ -408,6 +408,9 @@ dependencies {
|
||||
implementation(libs.zxing)
|
||||
implementation(libs.zxing.embedded)
|
||||
|
||||
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
|
||||
implementation(libs.osmdroid.android)
|
||||
|
||||
// Markdown
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.location
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
|
||||
/** Default close-up zoom for a single pinned location (street-level). */
|
||||
private const val DEFAULT_ZOOM = 16.0
|
||||
|
||||
/**
|
||||
* A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude]
|
||||
* with a single pin at that point.
|
||||
*
|
||||
* Used by the Roadstr road event cards (kind 1315/1316) in place of a
|
||||
* reverse-geocoded city name. Tiles are fetched from the public OSM tile
|
||||
* servers, which requires the app's package name as the User-Agent (set on
|
||||
* [Configuration] below) — without it OSM returns HTTP 403.
|
||||
*
|
||||
* Pan/zoom stay enabled, but a touch listener asks the parent to stop
|
||||
* intercepting gestures while the finger is on the map, so dragging the map
|
||||
* pans it instead of scrolling the surrounding feed.
|
||||
*/
|
||||
@Composable
|
||||
fun LocationPreviewMap(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
modifier: Modifier = Modifier,
|
||||
zoom: Double = DEFAULT_ZOOM,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val mapView =
|
||||
remember(context) {
|
||||
// Must be set before the MapView is created so OSM tile requests
|
||||
// carry a valid User-Agent (the default "osmdroid" is rejected).
|
||||
Configuration.getInstance().userAgentValue = context.packageName
|
||||
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setOnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
|
||||
MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
// Returning false lets the MapView still handle the pan/zoom.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// osmdroid's MapView is a plain Android View with its own lifecycle: it
|
||||
// needs onResume/onPause to (re)start its tile threads, and onDetach to
|
||||
// free the tile cache when the composable leaves the tree.
|
||||
DisposableEffect(lifecycleOwner, mapView) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
mapView.onDetach()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier.fillMaxWidth().height(180.dp),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
val point = GeoPoint(latitude, longitude)
|
||||
map.controller.setZoom(zoom)
|
||||
map.controller.setCenter(point)
|
||||
|
||||
map.overlays.removeAll { it is Marker }
|
||||
val marker =
|
||||
Marker(map).apply {
|
||||
position = point
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setInfoWindow(null)
|
||||
}
|
||||
map.overlays.add(marker)
|
||||
map.invalidate()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -20,30 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash
|
||||
|
||||
/** Emoji marker for a road event category — keeps the card icon-light and locale-independent. */
|
||||
private fun RoadEventType.emoji(): String =
|
||||
@@ -84,8 +84,8 @@ private fun RoadEventType.labelRes(): Int =
|
||||
* Self-contained card for a Roadstr road event report (kind 1315).
|
||||
*
|
||||
* Shows the category (emoji + localized label), the optional free-text comment,
|
||||
* and the location resolved from the finest published geohash. Identical in the
|
||||
* feed and the opened thread view, so it takes no `makeItShort` flag — mirroring
|
||||
* and a map pinned at the event's coordinates. Identical in the feed and the
|
||||
* opened thread view, so it takes no `makeItShort` flag — mirroring
|
||||
* [RenderBirdex].
|
||||
*/
|
||||
@Composable
|
||||
@@ -94,7 +94,7 @@ fun RenderRoadEventReport(baseNote: Note) {
|
||||
|
||||
val type = remember(noteEvent) { noteEvent.roadEventType() }
|
||||
val comment = remember(noteEvent) { noteEvent.content.trim() }
|
||||
val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } }
|
||||
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
|
||||
val title =
|
||||
@@ -116,9 +116,9 @@ fun RenderRoadEventReport(baseNote: Note) {
|
||||
)
|
||||
}
|
||||
|
||||
if (geohash != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
RoadEventLocation(geohash)
|
||||
if (point != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
RoadEventMap(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ fun RenderRoadEventConfirmation(baseNote: Note) {
|
||||
val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return
|
||||
|
||||
val status = remember(noteEvent) { noteEvent.status() }
|
||||
val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } }
|
||||
val point = remember(noteEvent) { noteEvent.roadEventPoint() }
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
|
||||
val emoji = if (status == RoadEventStatus.NO_LONGER_THERE) "❌" else "✅"
|
||||
@@ -161,26 +161,40 @@ fun RenderRoadEventConfirmation(baseNote: Note) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
if (geohash != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
RoadEventLocation(geohash)
|
||||
if (point != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
RoadEventMap(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */
|
||||
/** A rounded OpenStreetMap preview with a pin at the road event's [point]. */
|
||||
@Composable
|
||||
private fun RoadEventLocation(geohash: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "📍", style = MaterialTheme.typography.bodyMedium)
|
||||
LoadCityName(geohashStr = geohash) { cityName ->
|
||||
Text(
|
||||
text = cityName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun RoadEventMap(point: Pair<Double, Double>) {
|
||||
LocationPreviewMap(
|
||||
latitude = point.first,
|
||||
longitude = point.second,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best available coordinate for the card map: the event's explicit `lat`/`lon`
|
||||
* tags when present, otherwise the center of the finest published geohash.
|
||||
* Returns null when the event carries no location at all.
|
||||
*/
|
||||
private fun RoadEventReportEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
|
||||
|
||||
private fun RoadEventConfirmationEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
|
||||
|
||||
private fun resolveRoadEventPoint(
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
geohashes: List<String>,
|
||||
): Pair<Double, Double>? {
|
||||
if (latitude != null && longitude != null) return latitude to longitude
|
||||
|
||||
val finest = geohashes.maxByOrNull { it.length } ?: return null
|
||||
val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null
|
||||
return decoded.centerLat to decoded.centerLon
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ negentropyKmp = "v1.0.2"
|
||||
netUrlencoderLibVersion = "1.6.0"
|
||||
navigationCompose = "2.9.8"
|
||||
okhttp = "5.4.0"
|
||||
osmdroid = "6.1.20"
|
||||
runner = "1.7.0"
|
||||
secp256k1KmpJniAndroid = "0.23.0"
|
||||
schnorr256k1Kmp = "1.0.5"
|
||||
@@ -193,6 +194,7 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi
|
||||
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
|
||||
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
|
||||
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
|
||||
|
||||
Reference in New Issue
Block a user