From 2e32660d9bbfc081db37c70af0080f09b45bfef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:38:07 +0000 Subject: [PATCH] feat: roadstr-accurate road event rendering (kind 1315/1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align Amethyst's road event cards with the roadstr reference clients (https://github.com/jooray/roadstr) for both interoperability and a richer presentation. Interop: - Match roadstr's exact emoji set: road_closure ๐Ÿšซ (was โ›”) and other โ„น๏ธ (was ๐Ÿ“). The t codes and per-type TTLs already matched. - Make the kind 1316 NIP-31 alt status-dependent ("Roadstr: event confirmed" / "Roadstr: event denied") per the spec, instead of a single "Roadstr: event confirmation". Rendering: - Colored teardrop map pin per category, using roadstr's exact color palette, with the category emoji on the head (new MapPinIcon). - Freshness: fade the report pin to 0.6 under 25% of effective TTL and 0.4 once effectively expired, matching roadstr's opacity rule. - Subtitle meta line: "๐Ÿ•’ 23m ยท expires in 1h" / "ยท Expired" on reports and "๐Ÿ•’ 23m" on confirmations. - Confirmations get a green โœ… / red โŒ status pin. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../creators/location/LocationPreviewMap.kt | 23 +++ .../ui/note/creators/location/MapPinIcon.kt | 102 ++++++++++++ .../amethyst/ui/note/types/RoadEvent.kt | 148 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 2 + .../RoadEventConfirmationEvent.kt | 11 +- .../experimental/roadstr/RoadEventTest.kt | 6 +- 6 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 68f9a75992..8a03c6b6a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -27,6 +28,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle @@ -51,6 +54,11 @@ private const val DEFAULT_ZOOM = 16.0 * servers, which requires the app's package name as the User-Agent (set on * [Configuration] below) โ€” without it OSM returns HTTP 403. * + * When [pinColor] and [pinEmoji] are supplied the marker becomes a colored + * teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise + * osmdroid's default pin is used. [pinAlpha] fades the marker to signal + * freshness (e.g. an event close to its effective expiry). + * * 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. @@ -61,10 +69,23 @@ fun LocationPreviewMap( longitude: Double, modifier: Modifier = Modifier, zoom: Double = DEFAULT_ZOOM, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val markerIcon = + remember(pinColor, pinEmoji) { + if (pinColor != null && pinEmoji != null) { + val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) + BitmapDrawable(context.resources, bitmap) + } else { + null + } + } + val mapView = remember(context) { // Must be set before the MapView is created so OSM tile requests @@ -119,6 +140,8 @@ fun LocationPreviewMap( position = point setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setInfoWindow(null) + markerIcon?.let { icon = it } + alpha = pinAlpha } map.overlays.add(marker) map.invalidate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt new file mode 100644 index 0000000000..8e9f422ccc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -0,0 +1,102 @@ +/* + * 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.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import kotlin.math.roundToInt + +/** + * Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji] + * centered on its head, a white ring, and a soft drop shadow. + * + * The pin's tip sits at the bottom-center of the returned bitmap, so the + * osmdroid marker should be anchored at (0.5, 1.0) โ€” the geographic point then + * lands exactly under the tip. + * + * Matches the visual language of the roadstr reference clients + * (): the same per-category color is used so + * a pin reads the same across implementations. + */ +fun roadEventPinBitmap( + emoji: String, + colorArgb: Int, + density: Float, +): Bitmap { + fun dp(value: Float) = value * density + + val head = dp(34f) // head diameter + val radius = head / 2f + val tail = dp(12f) // pointer height below the head + val pad = dp(5f) // room for the drop shadow + val ringWidth = dp(2.5f) + + val width = (head + pad * 2).roundToInt() + val height = (head + tail + pad * 2).roundToInt() + val cx = width / 2f + val cy = pad + radius + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Head circle + pointer drawn as a single path so they share one shadow + // instead of seaming where the triangle meets the circle. + val body = + Path().apply { + addCircle(cx, cy, radius, Path.Direction.CW) + val mouth = radius * 0.7f + moveTo(cx - mouth, cy + radius * 0.55f) + lineTo(cx + mouth, cy + radius * 0.55f) + lineTo(cx, pad + head + tail) + close() + } + + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorArgb + style = Paint.Style.FILL + setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt()) + } + canvas.drawPath(body, fill) + + val ring = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ringWidth + } + canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring) + + // Color emoji fonts ignore the paint color, so the glyph keeps its own hue. + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = head * 0.58f + } + val metrics = text.fontMetrics + val baseline = cy - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(emoji, cx, baseline, text) + + return bitmap +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index 0c71ef351c..10eb368320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -31,12 +32,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext 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.LocationPreviewMap +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent @@ -44,15 +49,20 @@ import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEvent 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 +import com.vitorpamplona.quartz.utils.TimeUtils -/** Emoji marker for a road event category โ€” keeps the card icon-light and locale-independent. */ +/** + * Emoji marker for a road event category. Mirrors the icon set of the roadstr + * reference clients () so a report reads the + * same across implementations; keeps the card icon-light and locale-independent. + */ private fun RoadEventType.emoji(): String = when (this) { RoadEventType.POLICE -> "๐Ÿ‘ฎ" RoadEventType.SPEED_CAMERA -> "๐Ÿ“ท" RoadEventType.TRAFFIC_JAM -> "๐Ÿš—" RoadEventType.ACCIDENT -> "๐Ÿ’ฅ" - RoadEventType.ROAD_CLOSURE -> "โ›”" + RoadEventType.ROAD_CLOSURE -> "๐Ÿšซ" RoadEventType.CONSTRUCTION -> "๐Ÿšง" RoadEventType.HAZARD -> "โš ๏ธ" RoadEventType.ROAD_CONDITION -> "๐Ÿ›ฃ๏ธ" @@ -60,7 +70,29 @@ private fun RoadEventType.emoji(): String = RoadEventType.FOG -> "๐ŸŒซ๏ธ" RoadEventType.ICE -> "๐ŸงŠ" RoadEventType.ANIMAL -> "๐ŸฆŒ" - RoadEventType.OTHER -> "๐Ÿ“" + RoadEventType.OTHER -> "โ„น๏ธ" + } + +/** + * Per-category pin color. Uses the exact palette from the roadstr reference + * clients () so a pin's color carries the + * same meaning across the network. + */ +private fun RoadEventType.color(): Color = + when (this) { + RoadEventType.POLICE -> Color(0xFF0000FF) + RoadEventType.SPEED_CAMERA -> Color(0xFF800080) + RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00) + RoadEventType.ACCIDENT -> Color(0xFFFF0000) + RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000) + RoadEventType.CONSTRUCTION -> Color(0xFFFFD700) + RoadEventType.HAZARD -> Color(0xFFFF4500) + RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4) + RoadEventType.POTHOLE -> Color(0xFF795548) + RoadEventType.FOG -> Color(0xFF9E9E9E) + RoadEventType.ICE -> Color(0xFF00CED1) + RoadEventType.ANIMAL -> Color(0xFF4CAF50) + RoadEventType.OTHER -> Color(0xFF808080) } private fun RoadEventType.labelRes(): Int = @@ -91,10 +123,13 @@ private fun RoadEventType.labelRes(): Int = @Composable fun RenderRoadEventReport(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + val context = LocalContext.current val type = remember(noteEvent) { noteEvent.roadEventType() } val comment = remember(noteEvent) { noteEvent.content.trim() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } + val meta = remember(noteEvent) { noteEvent.metaLine(context) } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { val title = @@ -106,6 +141,13 @@ fun RenderRoadEventReport(baseNote: Note) { Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text( + text = meta, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + if (comment.isNotEmpty()) { Spacer(Modifier.height(6.dp)) Text( @@ -118,7 +160,12 @@ fun RenderRoadEventReport(baseNote: Note) { if (point != null) { Spacer(Modifier.height(8.dp)) - RoadEventMap(point) + RoadEventMap( + point = point, + pinColor = type?.color(), + pinEmoji = type?.emoji(), + pinAlpha = freshness, + ) } } } @@ -132,30 +179,33 @@ fun RenderRoadEventReport(baseNote: Note) { @Composable fun RenderRoadEventConfirmation(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return + val context = LocalContext.current val status = remember(noteEvent) { noteEvent.status() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val age = remember(noteEvent) { timeAgoNoDot(noteEvent.createdAt, context) } + + val denied = status == RoadEventStatus.NO_LONGER_THERE + val emoji = if (denied) "โŒ" else "โœ…" + val pinColor = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { - val emoji = if (status == RoadEventStatus.NO_LONGER_THERE) "โŒ" else "โœ…" - val titleRes = - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denied - } else { - R.string.road_event_confirmed - } + val titleRes = if (denied) R.string.road_event_denied else R.string.road_event_confirmed Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text( + text = "๐Ÿ•’ $age", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.height(4.dp)) Text( text = stringResource( - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denies_report - } else { - R.string.road_event_confirms_report - }, + if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report, ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, @@ -163,18 +213,30 @@ fun RenderRoadEventConfirmation(baseNote: Note) { if (point != null) { Spacer(Modifier.height(8.dp)) - RoadEventMap(point) + RoadEventMap( + point = point, + pinColor = pinColor, + pinEmoji = emoji, + ) } } } -/** A rounded OpenStreetMap preview with a pin at the road event's [point]. */ +/** A rounded OpenStreetMap preview with a colored emoji pin at the road event's [point]. */ @Composable -private fun RoadEventMap(point: Pair) { +private fun RoadEventMap( + point: Pair, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, +) { LocationPreviewMap( latitude = point.first, longitude = point.second, modifier = Modifier.clip(RoundedCornerShape(8.dp)), + pinColor = pinColor, + pinEmoji = pinEmoji, + pinAlpha = pinAlpha, ) } @@ -198,3 +260,51 @@ private fun resolveRoadEventPoint( val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null return decoded.centerLat to decoded.centerLon } + +/** + * Marker opacity by freshness, matching the roadstr clients: full while the + * report has plenty of life left, dimmed to 0.6 once under 25% of its effective + * TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the + * type (and therefore the TTL) is unknown. + */ +private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float { + val expiryAt = effectiveExpirationAt() ?: return 1f + val total = expiryAt - createdAt + if (total <= 0L) return 1f + + val fraction = (expiryAt - now).toFloat() / total + return when { + fraction <= 0f -> 0.4f + fraction < 0.25f -> 0.6f + else -> 1f + } +} + +/** "๐Ÿ•’ 23m ยท expires in 1h" (or "ยท expired") for the report card subtitle. */ +private fun RoadEventReportEvent.metaLine( + context: Context, + now: Long = TimeUtils.now(), +): String { + val age = "๐Ÿ•’ ${timeAgoNoDot(createdAt, context)}" + val expiryAt = effectiveExpirationAt() ?: return age + + val remaining = expiryAt - now + val expiry = + if (remaining <= 0L) { + stringRes(context, R.string.road_event_expired) + } else { + stringRes(context, R.string.road_event_expires_in, formatDuration(context, remaining)) + } + return "$age ยท $expiry" +} + +/** Compact forward duration ("2d" / "3h" / "45m") reusing the time-ago unit strings. */ +private fun formatDuration( + context: Context, + seconds: Long, +): String = + when { + seconds >= TimeUtils.ONE_DAY -> "${seconds / TimeUtils.ONE_DAY}${stringRes(context, R.string.d)}" + seconds >= TimeUtils.ONE_HOUR -> "${seconds / TimeUtils.ONE_HOUR}${stringRes(context, R.string.h)}" + else -> "${(seconds / TimeUtils.ONE_MINUTE).coerceAtLeast(1)}${stringRes(context, R.string.m)}" + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 56b204c08e..8c2070440f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3464,6 +3464,8 @@ No longer there Confirms a road report Reports a road event cleared + Expires in %1$s + Expired Goal amount (sats) 100000 Describe your goal diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt index 9259df9d45..746fe9200b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -89,7 +89,14 @@ class RoadEventConfirmationEvent( companion object { const val KIND = 1316 - const val ALT_DESCRIPTION = "Roadstr: event confirmation" + + /** NIP-31 fallback for a confirmation (`still_there`), per the roadstr spec. */ + const val ALT_CONFIRMED = "Roadstr: event confirmed" + + /** NIP-31 fallback for a denial (`no_longer_there`), per the roadstr spec. */ + const val ALT_DENIED = "Roadstr: event denied" + + fun altDescription(status: RoadEventStatus) = if (status == RoadEventStatus.NO_LONGER_THERE) ALT_DENIED else ALT_CONFIRMED fun build( reportId: HexKey, @@ -106,7 +113,7 @@ class RoadEventConfirmationEvent( coordinates(latitude, longitude) } expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) - alt(ALT_DESCRIPTION) + alt(altDescription(status)) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt index 16a7f82706..fd66eeb1d2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -69,7 +69,7 @@ class RoadEventTest { arrayOf("e", reportId), arrayOf("status", "no_longer_there"), arrayOf("expiration", "1701213200"), - arrayOf("alt", "Roadstr: event confirmation"), + arrayOf("alt", "Roadstr: event denied"), ), content = "", sig = "00".repeat(64), @@ -129,7 +129,7 @@ class RoadEventTest { assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) assertTrue(event.isDenial()) assertTrue(!event.isConfirmation()) - assertEquals("Roadstr: event confirmation", event.alt()) + assertEquals("Roadstr: event denied", event.alt()) } @Test @@ -161,7 +161,7 @@ class RoadEventTest { assertEquals(reportId, template.tags.first { it[0] == "e" }[1]) assertEquals("still_there", template.tags.first { it[0] == "status" }[1]) - assertEquals("Roadstr: event confirmation", template.tags.first { it[0] == "alt" }[1]) + assertEquals("Roadstr: event confirmed", template.tags.first { it[0] == "alt" }[1]) assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) }