Dashboard: surface the nearest hazard and the next signal change

Two cards below the status cards, each shown only when there is
something to show and each opening the V2X screen when tapped.

Hazard: cause name, distance, and a count of the others behind it,
ranked closest first. A hazard whose distance cannot be resolved,
because there is no fix yet, sorts last rather than being dropped.

Traffic light: the intersection changing soonest, its leading phase
with a countdown, and every signal group as a colour-coded chip. The
countdown runs on its own 500 ms clock rather than on SPATEM arrivals,
so it cannot freeze mid-count and keep claiming a light is about to
change after the RSU stops transmitting.

Signals are ranked by time-to-change rather than by distance because
SPATEM carries no position at all. Placing an intersection needs MAPEM
geometry, which nothing on air is currently sending.

Also fixes bottom-nav taps. One rule now applies to every tab: a tap
lands on that tab's own screen, popping back to it when it is still on
the stack so the gesture behaves exactly like Back or a back swipe.
saveState/restoreState are gone, since on this flat graph a restored
back stack brought back the sub-screen the rider was on instead of the
tab root, which is the opposite of what the tap asked for. Tabs also
now stay lit on the screens that belong to them.
This commit is contained in:
Ashin Walpola
2026-09-02 15:25:05 +02:00
parent 312f094909
commit ebe1c9edfd
5 changed files with 340 additions and 19 deletions
@@ -87,6 +87,12 @@ class MainActivity : AppCompatActivity() {
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
// Received hazards and live signal state, for the Dashboard's V2X summary cards.
// Both flows already expire their own entries on a clock, so nothing here has to
// decide when a hazard or a traffic light has gone stale.
val denmEvents by mqttViewModel.denmEvents.collectAsState()
val spatIntersections by mqttViewModel.spatIntersections.collectAsState()
val ownCamPosition by mqttViewModel.ownCamPosition.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
@@ -150,20 +156,29 @@ class MainActivity : AppCompatActivity() {
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
hazards = denmEvents,
signals = spatIntersections,
ownPosition = ownCamPosition,
onNavigateToConnection = { navController.navigate(Screen.Connection.route) },
onNavigateToSensors = {
navController.navigate(Screen.Sensors.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
restoreState = true
}
},
onNavigateToMap = { navController.navigate(Screen.Map.route) },
onNavigateToRecord = {
navController.navigate(Screen.Record.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
},
// Same options the bottom bar uses, so arriving at V2X from a
// Dashboard card leaves the same back stack as tapping the tab.
onNavigateToV2x = {
navController.navigate(Screen.MqttViewer.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
restoreState = true
}
},
)
@@ -76,6 +76,13 @@ private fun Screen.ownsRoute(route: String?): Boolean {
return when (this) {
Screen.Settings -> route.startsWith("settings/")
Screen.TripHistory -> route.startsWith("trip_review")
// Connection, Map and Sensors are only reachable from the Dashboard's own cards, and
// the session log only from Record, so those tabs stay lit while the rider is inside
// one of them. Without this the bar goes blank on screens that clearly belong to a tab.
Screen.Dashboard -> route == Screen.Connection.route ||
route == Screen.Map.route ||
route == Screen.Sensors.route
Screen.Record -> route == Screen.Log.route
else -> false
}
}
@@ -87,23 +94,26 @@ fun BottomNavBar(navController: NavController) {
NavigationBar {
bottomNavItems.forEach { screen ->
val onThisTab = screen.ownsRoute(currentRoute)
NavigationBarItem(
selected = onThisTab,
selected = screen.ownsRoute(currentRoute),
onClick = {
if (onThisTab && currentRoute != screen.route) {
// Already inside this tab, just deeper in: pop back to the tab's own
// screen. Navigating instead would restoreState the saved back stack and
// land straight back on the sub-screen, which reads as the tap doing
// nothing - the reason Settings > Connection could not be left by tapping
// Settings. Leaves the rest of the stack intact, so Back still works
// exactly as before.
navController.popBackStack(screen.route, inclusive = false)
} else {
navController.navigate(screen.route) {
popUpTo(Screen.Dashboard.route) { saveState = true }
launchSingleTop = true
restoreState = true
// One rule for every tab, including the one already selected: a tap lands on
// that tab's own screen. Nothing happens only when we are already on it.
if (currentRoute != screen.route) {
// Prefer a pop when this tab's screen is still on the back stack. That is
// exactly what Back or a back swipe would do, so tapping Settings from
// Settings > Connection, or Dashboard from the Map, behaves identically
// whichever way the rider asks for it. popBackStack reports false when the
// screen is not on the stack, which is the case for a genuine tab switch.
if (!navController.popBackStack(screen.route, inclusive = false)) {
// No saveState/restoreState here. The graph is flat, so a restored
// back stack brings back the sub-screen the rider was on rather than
// the tab's own screen, which is the opposite of what the tap asked
// for. Tab state that matters lives in the view models anyway.
navController.navigate(screen.route) {
popUpTo(Screen.Dashboard.route)
launchSingleTop = true
}
}
}
},
@@ -1,9 +1,12 @@
package com.hawhamburg.micr0bu.ui.screens
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +31,7 @@ import androidx.compose.material.icons.filled.GpsOff
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SensorsOff
import androidx.compose.material.icons.filled.Traffic
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -37,14 +41,18 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -53,7 +61,14 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.domain.cam.Cam
import com.hawhamburg.micr0bu.domain.denm.DenmEvent
import com.hawhamburg.micr0bu.domain.denm.DenmParser
import com.hawhamburg.micr0bu.domain.spat.SignalPhase
import com.hawhamburg.micr0bu.domain.spat.SpatIntersection
import com.hawhamburg.micr0bu.domain.usecase.GeoMath
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlinx.coroutines.delay
import kotlin.math.sqrt
@OptIn(ExperimentalMaterial3Api::class)
@@ -67,10 +82,14 @@ fun DashboardScreen(
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
hazards: List<DenmEvent> = emptyList(),
signals: List<SpatIntersection> = emptyList(),
ownPosition: Cam? = null,
onNavigateToConnection: () -> Unit,
onNavigateToSensors: () -> Unit,
onNavigateToMap: () -> Unit,
onNavigateToRecord: () -> Unit = {},
onNavigateToV2x: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@@ -357,6 +376,52 @@ fun DashboardScreen(
}
}
// Live V2X, below the status cards: the hazard that matters most and the signalised
// intersection about to change. Both are summaries of what the V2X screen shows in full,
// so tapping either opens that screen rather than repeating its detail here. One of each
// is shown deliberately: a dashboard read from a bike mount has room for the single most
// relevant thing, not for a list.
val ownLatLon = ownPosition?.let { it.latitude to it.longitude }
?: state.gnss?.let { it.latitude to it.longitude }
val rankedHazards = remember(hazards, ownLatLon) {
hazards
.map { denm ->
val distance = ownLatLon?.let { (lat, lon) ->
GeoMath.haversineMeters(lat, lon, denm.latitude, denm.longitude)
}
denm to distance
}
// Closest first. A hazard whose distance cannot be worked out, because there is
// no fix yet, sorts last rather than being dropped: it is still a real hazard,
// we just cannot say how far away it is.
.sortedBy { (_, d) -> d ?: Double.MAX_VALUE }
}
rankedHazards.firstOrNull()?.let { (denm, distance) ->
HazardCard(
hazard = denm,
distanceMeters = distance,
additionalCount = rankedHazards.size - 1,
onClick = onNavigateToV2x,
)
}
// Signals cannot be ranked by distance: SPATEM carries no position at all. The geometry
// that would place an intersection lives in MAPEM, which nothing on the air is currently
// sending. So the one shown is the one changing soonest, which is in any case the one a
// rider approaching a junction needs to see.
val nextSignal = remember(signals) {
val now = System.currentTimeMillis()
signals.minByOrNull { it.secondsToNextChange(now) ?: Double.MAX_VALUE }
}
nextSignal?.let { signal ->
SignalCard(
signal = signal,
additionalCount = signals.size - 1,
onClick = onNavigateToV2x,
)
}
Spacer(Modifier.height(4.dp))
if (state.pressureHpa != null)
@@ -485,3 +550,208 @@ private fun QuickStatRow(label: String, value: String) {
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
// Hazard red and the three signal states. Kept local to this file for the same reason the V2X
// screen keeps its own: these are traffic-light and warning semantics, not theme roles, and
// tying them to the colour scheme would let a theme change turn a red light amber.
private val HazardRed = Color(0xFFE53935)
private val HazardRedBg = Color(0xFF3A0A0A)
private val SignalGreen = Color(0xFF4CAF50)
private val SignalAmber = Color(0xFFFFC107)
private val SignalGray = Color(0xFF8B949E)
/** How many signal groups fit on the dashboard before the rest are summarised as a count. */
private const val DASH_MAX_SIGNAL_GROUPS = 6
/**
* Seconds until the first of this intersection's signal groups changes, or null when no group
* supplies a usable countdown. Marks already in the past are excluded: a change that has already
* happened says nothing about what the light will do next.
*/
private fun SpatIntersection.secondsToNextChange(nowMs: Long): Double? =
state.movements
.mapNotNull { it.current?.secondsUntil(nowMs) }
.filter { it >= 0.0 }
.minOrNull()
/**
* The nearest received hazard, as a glanceable summary.
*
* Deliberately says less than the V2X screen's row: what it is, how far away, and whether there
* are others behind it. Anything more detailed belongs on the screen this card opens.
*/
@Composable
private fun HazardCard(
hazard: DenmEvent,
distanceMeters: Double?,
additionalCount: Int,
onClick: () -> Unit,
) {
val title = DenmParser.causeCodeName(hazard.causeCode)
?: hazard.causeCode?.let {
stringResource(R.string.v2x_denm_rx_cause_code, it, hazard.subCauseCode ?: 0)
}
?: stringResource(R.string.v2x_map_denm_plain, hazard.stationId)
val detail = listOfNotNull(
distanceMeters?.let { stringResource(R.string.v2x_cam_rx_distance, it) }
?: stringResource(R.string.v2x_cam_rx_distance_unknown),
stringResource(R.string.dash_hazard_station, hazard.stationId),
if (additionalCount > 0) stringResource(R.string.dash_more_count, additionalCount) else null,
).joinToString(" · ")
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = HazardRedBg),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Default.Warning, null, tint = HazardRed, modifier = Modifier.size(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.dash_hazard_warning),
style = MaterialTheme.typography.labelLarge,
color = HazardRed,
fontWeight = FontWeight.SemiBold,
)
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(2.dp))
Text(
detail,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The signalised intersection changing soonest: its leading phase with a countdown, then every
* signal group as a coloured chip.
*
* Signal groups are bare numbers because that is all the app knows. Calling one "your lane" needs
* MAPEM geometry, and a friendlier label would claim knowledge that is not there.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SignalCard(
signal: SpatIntersection,
additionalCount: Int,
onClick: () -> Unit,
) {
// The countdown has to advance on its own clock. SPATEM repeats at about 2 Hz, so
// recomposition would roughly keep pace while the RSU is transmitting, but the moment it
// stops, a frozen "3 s" would go on claiming the light is about to change.
val nowMs = remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
nowMs.longValue = System.currentTimeMillis()
delay(500L)
}
}
val now = nowMs.longValue
val leading = signal.state.movements.minByOrNull { movement ->
movement.current?.secondsUntil(now)?.takeIf { it >= 0.0 } ?: Double.MAX_VALUE
}
val phase = leading?.current?.phase
val tint = phaseTint(phase)
val countdown = leading?.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
val hiddenGroups = signal.state.movements.size - DASH_MAX_SIGNAL_GROUPS
val footer = listOfNotNull(
if (hiddenGroups > 0) stringResource(R.string.dash_more_count, hiddenGroups) else null,
if (additionalCount > 0) stringResource(R.string.dash_signal_more, additionalCount) else null,
).joinToString(" · ")
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Default.Traffic, null, tint = tint, modifier = Modifier.size(28.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.dash_signal_title, signal.state.key),
style = MaterialTheme.typography.labelLarge,
color = tint,
fontWeight = FontWeight.SemiBold,
)
Text(
text = countdown
?.let { stringResource(R.string.dash_signal_countdown, phaseLabel(phase), it) }
?: phaseLabel(phase),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(6.dp))
// Wraps rather than scrolls: a horizontal scroller inside a scrolling dashboard
// is awkward to drive one-handed, and the chip row is short by construction.
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
signal.state.movements.take(DASH_MAX_SIGNAL_GROUPS).forEach { movement ->
val groupTint = phaseTint(movement.current?.phase)
val groupCountdown =
movement.current?.secondsUntil(now)?.takeIf { it in 0.0..99.0 }
Text(
text = stringResource(R.string.v2x_spat_group, movement.signalGroup) +
(groupCountdown?.let { " " + stringResource(R.string.v2x_spat_countdown, it) } ?: ""),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = groupTint,
modifier = Modifier
.padding(vertical = 2.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
.background(groupTint.copy(alpha = 0.15f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
if (footer.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
footer,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** Traffic-light colour for a phase: go is green, stop is red, anything in between is amber. */
@Composable
private fun phaseTint(phase: SignalPhase?): Color = when {
phase == null -> MaterialTheme.colorScheme.onSurfaceVariant
phase.isGo -> SignalGreen
phase.isStop -> HazardRed
phase.isTransition -> SignalAmber
else -> SignalGray
}
@Composable
private fun phaseLabel(phase: SignalPhase?): String = when {
phase == null -> stringResource(R.string.dash_signal_phase_unknown)
phase.isGo -> stringResource(R.string.dash_signal_phase_go)
phase.isStop -> stringResource(R.string.dash_signal_phase_stop)
phase.isTransition -> stringResource(R.string.dash_signal_phase_changing)
phase == SignalPhase.DARK -> stringResource(R.string.dash_signal_phase_dark)
else -> stringResource(R.string.dash_signal_phase_unknown)
}