mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #3277 from vitorpamplona/claude/modest-planck-o0fy3v
Roadstr: visual overhaul with map hero and category pills
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"
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.ColorFilter
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.vitorpamplona.amethyst.ui.theme.isLight
|
||||
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
|
||||
|
||||
/**
|
||||
* Night-mode filter for the (always-light) MAPNIK tiles so the map follows the
|
||||
* app theme. This is a lightness invert composed with a 180° hue rotation: it
|
||||
* darkens the map (light land → near-black) while *preserving* hue, so forests
|
||||
* stay green and water stays blue and the surfaces remain distinguishable —
|
||||
* unlike a plain colour invert (forests → magenta) or a desaturating invert
|
||||
* (everything → black).
|
||||
*/
|
||||
private val NIGHT_TILE_FILTER: ColorFilter =
|
||||
ColorMatrixColorFilter(
|
||||
floatArrayOf(
|
||||
0.574f,
|
||||
-1.430f,
|
||||
-0.144f,
|
||||
0f,
|
||||
255f,
|
||||
-0.426f,
|
||||
-0.430f,
|
||||
-0.144f,
|
||||
0f,
|
||||
255f,
|
||||
-0.426f,
|
||||
-1.430f,
|
||||
0.856f,
|
||||
0f,
|
||||
255f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
1f,
|
||||
0f,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
fun LocationPreviewMap(
|
||||
latitude: Double,
|
||||
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 darkTheme = !MaterialTheme.colorScheme.isLight
|
||||
|
||||
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
|
||||
// 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().aspectRatio(1f),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
val point = GeoPoint(latitude, longitude)
|
||||
map.controller.setZoom(zoom)
|
||||
map.controller.setCenter(point)
|
||||
|
||||
// Follow the app theme: dim the bright MAPNIK tiles in dark mode.
|
||||
map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null)
|
||||
|
||||
map.overlays.removeAll { it is Marker }
|
||||
val marker =
|
||||
Marker(map).apply {
|
||||
position = point
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setInfoWindow(null)
|
||||
markerIcon?.let { icon = it }
|
||||
alpha = pinAlpha
|
||||
}
|
||||
map.overlays.add(marker)
|
||||
map.invalidate()
|
||||
},
|
||||
)
|
||||
}
|
||||
+102
@@ -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,39 +20,50 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.Surface
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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
|
||||
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 +71,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 =
|
||||
@@ -83,10 +116,10 @@ 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
|
||||
* [RenderBirdex].
|
||||
* A map hero pinned at the event's coordinates, with a floating category pill
|
||||
* (colored per [RoadEventType.color]) and the optional free-text comment below.
|
||||
* Identical in the feed and the opened thread view, so it takes no `makeItShort`
|
||||
* flag — mirroring [RenderBirdex].
|
||||
*/
|
||||
@Composable
|
||||
fun RenderRoadEventReport(baseNote: Note) {
|
||||
@@ -94,93 +127,175 @@ 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() }
|
||||
val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() }
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
|
||||
val title =
|
||||
if (type != null) {
|
||||
"${type.emoji()} ${stringResource(type.labelRes())}"
|
||||
} else {
|
||||
stringResource(R.string.road_event_unknown)
|
||||
}
|
||||
val color = type?.color() ?: MaterialTheme.colorScheme.outline
|
||||
val emoji = type?.emoji() ?: "📍"
|
||||
val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown)
|
||||
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
if (comment.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = comment,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (geohash != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
RoadEventLocation(geohash)
|
||||
}
|
||||
}
|
||||
RoadEventCard(
|
||||
chipColor = color,
|
||||
chipEmoji = emoji,
|
||||
chipLabel = label,
|
||||
point = point,
|
||||
pinAlpha = freshness,
|
||||
comment = comment,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained card for a Roadstr confirmation/denial (kind 1316).
|
||||
*
|
||||
* Shows whether the referenced report was confirmed (still there) or denied
|
||||
* (no longer there), plus a one-line description of the action.
|
||||
* Same hero/pill layout as a report, with a green ✅ "Still there" or red ❌
|
||||
* "No longer there" pill and a one-line description of the action.
|
||||
*/
|
||||
@Composable
|
||||
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 "✅"
|
||||
val titleRes =
|
||||
if (status == RoadEventStatus.NO_LONGER_THERE) {
|
||||
R.string.road_event_denied
|
||||
} else {
|
||||
R.string.road_event_confirmed
|
||||
}
|
||||
val denied = status == RoadEventStatus.NO_LONGER_THERE
|
||||
val emoji = if (denied) "❌" else "✅"
|
||||
val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50)
|
||||
|
||||
Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
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
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
if (geohash != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
RoadEventLocation(geohash)
|
||||
}
|
||||
}
|
||||
RoadEventCard(
|
||||
chipColor = color,
|
||||
chipEmoji = emoji,
|
||||
chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed),
|
||||
point = point,
|
||||
subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report),
|
||||
)
|
||||
}
|
||||
|
||||
/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */
|
||||
/**
|
||||
* Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a
|
||||
* floating [CategoryPill] overlaid on its top-start, and an optional
|
||||
* [subtitle] + [comment] block below. When [point] is null the pill stands on
|
||||
* its own so the card still reads.
|
||||
*/
|
||||
@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 RoadEventCard(
|
||||
chipColor: Color,
|
||||
chipEmoji: String,
|
||||
chipLabel: String,
|
||||
point: Pair<Double, Double>?,
|
||||
pinAlpha: Float = 1f,
|
||||
comment: String = "",
|
||||
subtitle: String? = null,
|
||||
) {
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
if (point != null) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
LocationPreviewMap(
|
||||
latitude = point.first,
|
||||
longitude = point.second,
|
||||
pinColor = chipColor,
|
||||
pinEmoji = chipEmoji,
|
||||
pinAlpha = pinAlpha,
|
||||
)
|
||||
CategoryPill(
|
||||
color = chipColor,
|
||||
emoji = chipEmoji,
|
||||
label = chipLabel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CategoryPill(
|
||||
color = chipColor,
|
||||
emoji = chipEmoji,
|
||||
label = chipLabel,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (subtitle != null || comment.isNotEmpty()) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
if (comment.isNotEmpty()) {
|
||||
if (subtitle != null) Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = comment,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */
|
||||
@Composable
|
||||
private fun CategoryPill(
|
||||
color: Color,
|
||||
emoji: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White,
|
||||
shape = RoundedCornerShape(50),
|
||||
shadowElevation = 3.dp,
|
||||
) {
|
||||
Text(
|
||||
text = "$emoji $label",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -86,7 +88,9 @@ class GeoHashFeedFilter(
|
||||
event is MusicTrackEvent ||
|
||||
event is MusicPlaylistEvent ||
|
||||
event is PodcastEpisodeEvent ||
|
||||
event is PodcastMetadataEvent
|
||||
event is PodcastMetadataEvent ||
|
||||
event is RoadEventReportEvent ||
|
||||
event is RoadEventConfirmationEvent
|
||||
) &&
|
||||
event.isTaggedGeoHash(geohash)
|
||||
|
||||
|
||||
+4
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
|
||||
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -57,6 +59,8 @@ val PostsByGeohashKinds =
|
||||
PodcastEpisodeEvent.KIND,
|
||||
PodcastMetadataEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
RoadEventReportEvent.KIND,
|
||||
RoadEventConfirmationEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterPostsByGeohash(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+9
-2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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" })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user