From 7abeb58432ad918782fbaf25e4589a08c6f32a1c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 9 Aug 2026 07:47:13 -0700 Subject: [PATCH] Car dashboard: POTA-to-validate, ROTA miles, and session-fallback rows on the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the CarPlay/Android Auto status-dashboard design onto dev's pane path (the surface/map variant was removed in #729). The activation block now shows, via pure helpers in CarQsoStatus.kt: - POTA row with a "N more to validate the activation" / "Activation validated" secondary (POTA_ACTIVATION_TARGET = 10). - ROTA row with a "X.X mi driven this activation" secondary; car_rota_line is split to a 2-arg title + car_rota_miles secondary. - Session-fallback row ("Session · N QSOs" / "Last logged JA1XYZ · 20m · 41 min") when neither POTA nor ROTA is active — the design's "activation rows drop out, session stats take the slot." - Band row gains a "N decodes last cycle" secondary, read from currentMessages (per-cycle overlay, so it drops to 0 on a silent slot) rather than the cross-cycle mutableFt8MessageList. Activation rows keep #729's row-priority selection, so on a tight host they replace the band line instead of being dropped. buildCarActivationRows, potaValidateSpec, buildCarSessionRow, formatMiles, minutesAgo, and carDecodesSecondary are unit-tested in CarDashboardTest (17 cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/car/CarQsoStatus.kt | 129 +++++++++++-- .../radio/ks3ckc/ft8af/car/QsoStatusScreen.kt | 61 ++++-- .../src/main/res/values/strings_compose.xml | 10 +- .../ks3ckc/ft8af/car/CarDashboardTest.kt | 182 ++++++++++++++++++ .../ks3ckc/ft8af/car/CarQsoStatusTest.kt | 22 +-- 5 files changed, 358 insertions(+), 46 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarDashboardTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt index f92878fd..6d122ea6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt @@ -146,24 +146,125 @@ internal fun buildCarPotaLine(parkRefsDisplay: String?, qsoCount: Int?): CarStri return CarStringSpec(R.string.car_pota_line, listOf(parkRefsDisplay, qsoCount ?: 0)) } +/** A two-line row of the Android Auto status pane: a title and an optional secondary line. */ +internal data class CarPaneRow(val title: CarStringSpec, val secondary: CarStringSpec? = null) + +/** + * QSOs a POTA activation needs before it counts under the POTA program rules. + * There is no constant for this in the POTA session code (the phone UI never + * shows a remaining-to-validate figure), so the well-known program rule lives + * here where the car dashboard uses it. + */ +internal const val POTA_ACTIVATION_TARGET = 10 + +/** + * Secondary line for the POTA row: "N more to validate the activation" while the + * count is below [POTA_ACTIVATION_TARGET], then "Activation validated". Counts at + * or above the target (including hand-logged overshoot) clamp to validated. + */ +internal fun potaValidateSpec(qsoCount: Int): CarStringSpec { + val remaining = (POTA_ACTIVATION_TARGET - qsoCount).coerceAtLeast(0) + return if (remaining > 0) { + CarStringSpec(R.string.car_pota_to_validate, listOf(remaining)) + } else { + CarStringSpec(R.string.car_pota_validated) + } +} + +/** "0.0" / "12.3" — one decimal, locale-independent so tests are stable. */ +internal fun formatMiles(miles: Double): String = + String.format(java.util.Locale.US, "%.1f", miles) + +/** + * Whole minutes between [thenMs] and [nowMs] for the "last logged … N min" line. + * Returns null when there is no timestamp (0/null) or the clock is skewed so [thenMs] + * is in the future, so the session row degrades to "No QSOs logged yet" rather than + * showing a nonsense figure. + */ +internal fun minutesAgo(nowMs: Long, thenMs: Long?): Int? { + if (thenMs == null || thenMs <= 0L) return null + val delta = nowMs - thenMs + if (delta < 0L) return null + return (delta / 60_000L).toInt() +} + /** - * "ROTA Route 66 · 12 QSOs · 45.3 mi"; null when no trip is running. The QSO - * count is sent+pending so contacts logged out of coverage still show, and the - * miles match the trip notification's one-decimal format. + * The session-summary row shown when no POTA/ROTA activation is running. The title + * is always the session QSO count; the secondary reports the most recent logged + * contact ("Last logged JA1XYZ · 20m · 41 min") when one is known, degrading to a + * band-less form, then to "No QSOs logged yet" when [lastQsoCallsign] or + * [lastQsoMinutesAgo] is missing. */ -internal fun buildCarRotaLine( - active: Boolean, - tripName: String, - sentQsos: Int, - pendingQsos: Int, - miles: Double, -): CarStringSpec? { - if (!active) return null - val name = tripName.trim().ifEmpty { "trip" } - val milesLabel = String.format(java.util.Locale.US, "%.1f", miles) - return CarStringSpec(R.string.car_rota_line, listOf(name, sentQsos + pendingQsos, milesLabel)) +internal fun buildCarSessionRow( + sessionQsoCount: Int, + lastQsoCallsign: String?, + lastQsoBandName: String?, + lastQsoMinutesAgo: Int?, +): CarPaneRow { + val title = CarStringSpec(R.string.car_session_line, listOf(sessionQsoCount)) + val call = lastQsoCallsign?.takeIf { it.isNotBlank() } + val secondary = if (call != null && lastQsoMinutesAgo != null) { + val band = lastQsoBandName?.takeIf { it.isNotBlank() } + if (band != null) { + CarStringSpec(R.string.car_session_last, listOf(call, band, lastQsoMinutesAgo)) + } else { + CarStringSpec(R.string.car_session_last_noband, listOf(call, lastQsoMinutesAgo)) + } + } else { + CarStringSpec(R.string.car_session_none) + } + return CarPaneRow(title, secondary) } +/** + * The activation block of the car status pane. Emits a POTA row (with a + * "N to validate" secondary) and/or a ROTA row (with a "X.X mi driven this + * activation" secondary) for whichever activations are running; when neither is + * active the block collapses to a single session-summary row (the design's + * "activation rows drop out, session stats take the slot"). POTA and ROTA are + * practically mutually exclusive — parked at a park vs. roving on roads — but + * both are emitted if both happen to be active, ordered POTA then ROTA. + */ +internal fun buildCarActivationRows( + potaActive: Boolean, + potaParkRefsDisplay: String?, + potaQsoCount: Int, + rotaActive: Boolean, + rotaTripName: String?, + rotaQsoCount: Int, + rotaMiles: Double, + sessionQsoCount: Int, + lastQsoCallsign: String?, + lastQsoBandName: String?, + lastQsoMinutesAgo: Int?, +): List { + val rows = mutableListOf() + if (potaActive) { + buildCarPotaLine(potaParkRefsDisplay, potaQsoCount)?.let { + rows.add(CarPaneRow(title = it, secondary = potaValidateSpec(potaQsoCount))) + } + } + if (rotaActive && !rotaTripName.isNullOrBlank()) { + rows.add( + CarPaneRow( + title = CarStringSpec(R.string.car_rota_line, listOf(rotaTripName, rotaQsoCount)), + secondary = CarStringSpec(R.string.car_rota_miles, listOf(formatMiles(rotaMiles))), + ), + ) + } + if (rows.isEmpty()) { + rows.add(buildCarSessionRow(sessionQsoCount, lastQsoCallsign, lastQsoBandName, lastQsoMinutesAgo)) + } + return rows +} + +/** + * Secondary line for the band row: "N decodes last cycle" (null when there were no + * decodes, so the row shows the frequency alone rather than "0 decodes"). + */ +internal fun carDecodesSecondary(decodeCount: Int): CarStringSpec? = + if (decodeCount > 0) CarStringSpec(R.string.car_decodes_last_cycle, listOf(decodeCount)) else null + /** One row of the car's recent-decodes list. */ internal data class CarDecodeRow(val utcTimeMs: Long, val text: String, val snrLabel: String?) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt index 734536cd..960335ce 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt @@ -128,20 +128,27 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec .setTitle(status.seqLine?.let { resolve(it) } ?: resolve(status.slotLine)) .apply { if (status.seqLine != null) addText(resolve(status.slotLine)) } .build(), - Row.Builder().setTitle(status.bandLine).build(), + Row.Builder().setTitle(status.bandLine) + .apply { + // Per-cycle decode count: currentMessages is the label overlay, + // refreshed each cycle and cleared on a silent slot, so it drops + // to 0 correctly (mutableFt8MessageList accumulates across cycles + // when clearDecodesEveryCycle is off, the default). + carDecodesSecondary(vm.currentMessages?.size ?: 0)?.let { addText(resolve(it)) } + } + .build(), ) val priorities = mutableListOf(CAR_ROW_HEADLINE, CAR_ROW_SEQ_SLOT, CAR_ROW_BAND) - // POTA / ROTA rows only exist while an activation or trip is running. - // StateFlow reads (not observers) are enough for freshness: the 1 Hz tick - // re-renders the pane every second anyway. - val activation = PotaSessionManager.currentActivation.value - buildCarPotaLine(activation?.parkRefsDisplay, activation?.qsoCount)?.let { - rows.add(Row.Builder().setTitle(resolve(it)).build()) - priorities.add(CAR_ROW_ACTIVATION) - } - val trip = RotaTripManager.state.value - buildCarRotaLine(trip.active, trip.tripName, trip.sentQsos, trip.pendingQsos, trip.miles)?.let { - rows.add(Row.Builder().setTitle(resolve(it)).build()) + // Activation dashboard: POTA and/or ROTA rows while activating, otherwise a + // single session-summary row (see buildCarActivationRows). Plain StateFlow + // reads are enough for freshness — the 1 Hz tick re-renders every second. + buildActivationPaneRows(vm).forEach { row -> + rows.add( + Row.Builder() + .setTitle(resolve(row.title)) + .apply { row.secondary?.let { addText(resolve(it)) } } + .build(), + ) priorities.add(CAR_ROW_ACTIVATION) } val pane = Pane.Builder().apply { @@ -165,6 +172,36 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec .build() } + /** + * Reads the current POTA/ROTA activation and session state and maps it to the + * pane's activation rows via the pure [buildCarActivationRows]. "Session QSOs" + * uses the today/yesterday worked-callsign set + * ([GeneralVariables.QSL_Callsign_list_today]) — the only cheap in-memory count — + * and "last logged" is best-effort: the just-completed QSO timestamp + * ([com.k1af.ft8af.ft8transmit.FT8TransmitSignal.mutableQsoCompletedAt], stamped + * with [UtcTimer]) with the current partner callsign and tuned band. + */ + private fun buildActivationPaneRows(vm: MainViewModel): List { + val pota = PotaSessionManager.currentActivation.value + val rota = RotaTripManager.state.value + return buildCarActivationRows( + potaActive = pota != null, + potaParkRefsDisplay = pota?.parkRefsDisplay, + potaQsoCount = pota?.qsoCount ?: 0, + rotaActive = rota.active, + rotaTripName = rota.tripName, + rotaQsoCount = rota.sentQsos + rota.pendingQsos, + rotaMiles = rota.miles, + sessionQsoCount = GeneralVariables.QSL_Callsign_list_today.size, + lastQsoCallsign = vm.ft8TransmitSignal.mutableToCallsign.value?.callsign, + lastQsoBandName = currentBandName(), + lastQsoMinutesAgo = minutesAgo( + UtcTimer.getSystemTime(), + vm.ft8TransmitSignal.mutableQsoCompletedAt.value, + ), + ) + } + private fun resolve(spec: CarStringSpec): String = carContext.getString(spec.resId, *spec.args.toTypedArray()) diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index bfaf5999..ef973485 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -914,7 +914,15 @@ Recent decodes No decodes yet POTA %1$s · %2$d QSOs - ROTA %1$s · %2$d QSOs · %3$s mi + %1$d more to validate the activation + Activation validated + ROTA %1$s · %2$d QSOs + %1$s mi driven this activation + %1$d decodes last cycle + Session · %1$d QSOs + Last logged %1$s · %2$s · %3$d min + Last logged %1$s · %2$d min + No QSOs logged yet Roads On The Air (ROTA) diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarDashboardTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarDashboardTest.kt new file mode 100644 index 00000000..88aea636 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarDashboardTest.kt @@ -0,0 +1,182 @@ +package radio.ks3ckc.ft8af.car + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.R +import org.junit.Test + +/** + * Tests for the pure Android Auto dashboard helpers — the POTA/ROTA/session + * activation block, the "N to validate" figure, mileage formatting, and the + * "minutes ago" clamp. The Screen only resolves the returned specs, so these + * tests pin the row-selection and formatting rules. + */ +class CarDashboardTest { + + // -- potaValidateSpec -- + + @Test + fun potaValidate_belowTarget_countsRemaining() { + val spec = potaValidateSpec(qsoCount = 3) + assertThat(spec.resId).isEqualTo(R.string.car_pota_to_validate) + assertThat(spec.args).containsExactly(POTA_ACTIVATION_TARGET - 3) + } + + @Test + fun potaValidate_zeroQsos_remainingIsFullTarget() { + assertThat(potaValidateSpec(0).args).containsExactly(POTA_ACTIVATION_TARGET) + } + + @Test + fun potaValidate_atOrAboveTarget_isValidated() { + for (count in listOf(POTA_ACTIVATION_TARGET, POTA_ACTIVATION_TARGET + 5)) { + assertThat(potaValidateSpec(count).resId).isEqualTo(R.string.car_pota_validated) + } + } + + // -- formatMiles -- + + @Test + fun formatMiles_oneDecimal_localeIndependent() { + assertThat(formatMiles(0.0)).isEqualTo("0.0") + assertThat(formatMiles(12.34)).isEqualTo("12.3") + assertThat(formatMiles(12.36)).isEqualTo("12.4") + } + + // -- minutesAgo -- + + @Test + fun minutesAgo_nullOrNonPositiveTimestamp_isNull() { + assertThat(minutesAgo(nowMs = 60_000L, thenMs = null)).isNull() + assertThat(minutesAgo(nowMs = 60_000L, thenMs = 0L)).isNull() + assertThat(minutesAgo(nowMs = 60_000L, thenMs = -5L)).isNull() + } + + @Test + fun minutesAgo_futureTimestamp_isNull() { + assertThat(minutesAgo(nowMs = 1_000L, thenMs = 5_000L)).isNull() + } + + @Test + fun minutesAgo_flooredToWholeMinutes() { + assertThat(minutesAgo(nowMs = 41 * 60_000L, thenMs = 0L + 1L)).isEqualTo(40) + assertThat(minutesAgo(nowMs = 90_000L, thenMs = 1L)).isEqualTo(1) + assertThat(minutesAgo(nowMs = 30_000L, thenMs = 1L)).isEqualTo(0) + } + + // -- buildCarSessionRow -- + + @Test + fun sessionRow_titleCarriesCount() { + val row = buildCarSessionRow(5, "JA1XYZ", "20m", 41) + assertThat(row.title.resId).isEqualTo(R.string.car_session_line) + assertThat(row.title.args).containsExactly(5) + } + + @Test + fun sessionRow_fullLastLogged_withBand() { + val row = buildCarSessionRow(5, "JA1XYZ", "20m", 41) + assertThat(row.secondary?.resId).isEqualTo(R.string.car_session_last) + assertThat(row.secondary?.args).containsExactly("JA1XYZ", "20m", 41).inOrder() + } + + @Test + fun sessionRow_lastLogged_blankBandDropsToNoBandForm() { + for (band in listOf(null, "", " ")) { + val row = buildCarSessionRow(5, "JA1XYZ", band, 41) + assertThat(row.secondary?.resId).isEqualTo(R.string.car_session_last_noband) + assertThat(row.secondary?.args).containsExactly("JA1XYZ", 41).inOrder() + } + } + + @Test + fun sessionRow_noneWhenCallsignOrMinutesMissing() { + assertThat(buildCarSessionRow(0, null, "20m", 41).secondary?.resId) + .isEqualTo(R.string.car_session_none) + assertThat(buildCarSessionRow(0, " ", "20m", 41).secondary?.resId) + .isEqualTo(R.string.car_session_none) + assertThat(buildCarSessionRow(3, "JA1XYZ", "20m", null).secondary?.resId) + .isEqualTo(R.string.car_session_none) + } + + // -- buildCarActivationRows -- + + private fun rows( + potaActive: Boolean = false, + potaParkRefsDisplay: String? = null, + potaQsoCount: Int = 0, + rotaActive: Boolean = false, + rotaTripName: String? = null, + rotaQsoCount: Int = 0, + rotaMiles: Double = 0.0, + sessionQsoCount: Int = 5, + lastQsoCallsign: String? = "JA1XYZ", + lastQsoBandName: String? = "20m", + lastQsoMinutesAgo: Int? = 41, + ) = buildCarActivationRows( + potaActive, potaParkRefsDisplay, potaQsoCount, + rotaActive, rotaTripName, rotaQsoCount, rotaMiles, + sessionQsoCount, lastQsoCallsign, lastQsoBandName, lastQsoMinutesAgo, + ) + + @Test + fun activationRows_potaOnly_potaRowWithValidateSecondary() { + val r = rows(potaActive = true, potaParkRefsDisplay = "K-1234", potaQsoCount = 12) + assertThat(r).hasSize(1) + assertThat(r[0].title.resId).isEqualTo(R.string.car_pota_line) + assertThat(r[0].title.args).containsExactly("K-1234", 12).inOrder() + // 12 >= target → validated + assertThat(r[0].secondary?.resId).isEqualTo(R.string.car_pota_validated) + } + + @Test + fun activationRows_rotaOnly_rotaRowWithMilesSecondary() { + val r = rows(rotaActive = true, rotaTripName = "Route 66", rotaQsoCount = 0, rotaMiles = 0.0) + assertThat(r).hasSize(1) + assertThat(r[0].title.resId).isEqualTo(R.string.car_rota_line) + assertThat(r[0].title.args).containsExactly("Route 66", 0).inOrder() + assertThat(r[0].secondary?.resId).isEqualTo(R.string.car_rota_miles) + assertThat(r[0].secondary?.args).containsExactly("0.0") + } + + @Test + fun activationRows_bothActive_potaThenRota_noSession() { + val r = rows( + potaActive = true, potaParkRefsDisplay = "K-1234", potaQsoCount = 3, + rotaActive = true, rotaTripName = "Route 66", rotaQsoCount = 2, rotaMiles = 8.7, + ) + assertThat(r).hasSize(2) + assertThat(r[0].title.resId).isEqualTo(R.string.car_pota_line) + assertThat(r[0].secondary?.resId).isEqualTo(R.string.car_pota_to_validate) + assertThat(r[1].title.resId).isEqualTo(R.string.car_rota_line) + } + + @Test + fun activationRows_neitherActive_collapsesToSessionRow() { + val r = rows() + assertThat(r).hasSize(1) + assertThat(r[0].title.resId).isEqualTo(R.string.car_session_line) + assertThat(r[0].secondary?.resId).isEqualTo(R.string.car_session_last) + } + + @Test + fun activationRows_activeFlagButBlankLabel_treatedAsInactive() { + // POTA "active" with no park ref and ROTA "active" with no trip name both + // drop out, so the block collapses to the session row. + val r = rows( + potaActive = true, potaParkRefsDisplay = " ", + rotaActive = true, rotaTripName = "", + ) + assertThat(r).hasSize(1) + assertThat(r[0].title.resId).isEqualTo(R.string.car_session_line) + } + + // -- carDecodesSecondary -- + + @Test + fun decodesSecondary_nullWhenZero_specWhenPositive() { + assertThat(carDecodesSecondary(0)).isNull() + val spec = carDecodesSecondary(12) + assertThat(spec?.resId).isEqualTo(R.string.car_decodes_last_cycle) + assertThat(spec?.args).containsExactly(12) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatusTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatusTest.kt index 4b6da476..b5c34c4b 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatusTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatusTest.kt @@ -237,25 +237,9 @@ class CarQsoStatusTest { assertThat(buildCarPotaLine("K-1234", null)!!.args).containsExactly("K-1234", 0).inOrder() } - @Test - fun buildCarRotaLine_inactiveTrip_hidesRow() { - assertThat(buildCarRotaLine(false, "Route 66", 5, 2, 12.0)).isNull() - } - - @Test - fun buildCarRotaLine_sumsSentAndPendingQsos_andFormatsMiles() { - val spec = buildCarRotaLine(true, "Route 66", 10, 2, 45.34)!! - assertThat(spec.resId).isEqualTo(R.string.car_rota_line) - // 12 = 10 sent + 2 still queued offline; miles use the trip - // notification's one-decimal format. - assertThat(spec.args).containsExactly("Route 66", 12, "45.3").inOrder() - } - - @Test - fun buildCarRotaLine_blankTripName_fallsBackToTrip() { - assertThat(buildCarRotaLine(true, " ", 0, 0, 0.0)!!.args) - .containsExactly("trip", 0, "0.0").inOrder() - } + // The ROTA row (title + miles secondary) and the session-fallback row are + // built inside buildCarActivationRows; their formatting is covered in + // CarDashboardTest. @Test fun selectCarPaneRows_underLimit_keepsAllInOrder() {