diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 48d4d0b..ac5804c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -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 } }, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index e7cf0f4..c44f205 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -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 + } } } }, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt index fb13725..adbba49 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt @@ -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 = emptyList(), + signals: List = 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) +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0e65240..23a2525 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -31,6 +31,19 @@ Messwerte Fahrsitzung starten Wird initialisiert… + + + Gefahrenwarnung + Station %1$d + +%1$d weitere + Ampel %1$s + +%1$d weitere Kreuzung(en) + Grün + Rot + Wechselt + Dunkel + Unbekannt + %1$s · %2$.0f s Luftdruck Höhe Richtung diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f249648..77ac61c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,19 @@ samples Start Driving Session Initialising… + + + Hazard warning + station %1$d + +%1$d more + Traffic light %1$s + +%1$d more intersection(s) + Green + Red + Changing + Dark + Unknown + %1$s · %2$.0f s Pressure Altitude Heading