feat: roadstr-accurate road event rendering (kind 1315/1316)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
This commit is contained in:
Claude
2026-06-18 22:38:07 +00:00
parent 31a375034a
commit 2e32660d9b
6 changed files with 268 additions and 24 deletions
@@ -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()
@@ -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
* (<https://github.com/jooray/roadstr>): 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
}
@@ -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 (<https://github.com/jooray/roadstr>) 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 (<https://github.com/jooray/roadstr>) 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<Double, Double>) {
private fun RoadEventMap(
point: Pair<Double, Double>,
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)}"
}
+2
View File
@@ -3464,6 +3464,8 @@
<string name="road_event_denied">No longer there</string>
<string name="road_event_confirms_report">Confirms a road report</string>
<string name="road_event_denies_report">Reports a road event cleared</string>
<string name="road_event_expires_in">Expires in %1$s</string>
<string name="road_event_expired">Expired</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -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()
}
@@ -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" })
}