From 533bda7f25c8017dafbc3cb526ff10c0e7d94e81 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 9 Aug 2026 00:19:20 -0700 Subject: [PATCH 1/2] Car status dashboard: ROTA + POTA-to-validate + session-fallback rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Android Auto status screen with the activation dashboard from the CarPlay design exploration (Pane layout). Adds, beside the existing POTA line: - a ROTA (Roads On The Air) row — "ROTA · N QSOs" with mileage - a POTA "N more to validate the activation" / "Activation validated" secondary (POTA's 10-QSO program rule; no existing constant) - a "N decodes last cycle" secondary on the band row - a session-summary row ("Session · N QSOs" / "Last logged …") that takes the slot when no POTA/ROTA activation is running — the design's "activation rows drop out, session stats take the slot" behavior The decision/format logic lives in pure, Android-free helpers in CarQsoStatus.kt (buildCarActivationRows, potaValidateSpec, buildCarSessionRow, formatMiles, minutesAgo, carDecodesSecondary), unit-tested in CarDashboardTest. QsoStatusScreen maps the app singletons (PotaSessionManager, RotaTripManager, GeneralVariables, FT8TransmitSignal) into those helpers. Both render paths use the same builder: the PaneTemplate fallback (older hosts) and the map Surface overlay (modern hosts) — CarSurfaceState.potaText is generalized to activationLines so ROTA/session show on real devices too. buildCarQsoStatus is left untouched, so the shared status/slot/band logic and its tests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/car/CarMapSurfaceRenderer.kt | 2 +- .../radio/ks3ckc/ft8af/car/CarQsoStatus.kt | 118 ++++++++++++ .../radio/ks3ckc/ft8af/car/CarSurfaceState.kt | 14 +- .../radio/ks3ckc/ft8af/car/QsoStatusScreen.kt | 57 +++++- .../src/main/res/values/strings_compose.xml | 9 + .../ks3ckc/ft8af/car/CarDashboardTest.kt | 182 ++++++++++++++++++ 6 files changed, 369 insertions(+), 13 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/CarMapSurfaceRenderer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarMapSurfaceRenderer.kt index f6527cfbf..a14018fcc 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarMapSurfaceRenderer.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarMapSurfaceRenderer.kt @@ -324,7 +324,7 @@ internal class CarMapSurfaceRenderer { ) val bottomLines = buildList { add(PanelLine(state.bandText, TEXT_MUTED, detailSize, bold = false)) - state.potaText?.let { add(PanelLine(it, TEXT_ACCENT, detailSize, bold = false)) } + state.activationLines.forEach { add(PanelLine(it, TEXT_ACCENT, detailSize, bold = false)) } } // Anchor flush to the system bars when known (top banner just under the status 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 1b58ae0ef..5f9101d1b 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 @@ -156,6 +156,124 @@ internal fun buildCarPotaLine(parkRefsDisplay: String?, qsoCount: Int?): CarStri return CarStringSpec(R.string.car_pota_line, listOf(parkRefsDisplay, count)) } +/** 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() +} + +/** + * 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 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 and/or a ROTA row + * 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 + /** ARGB colour for "who heard me" PSK markers \u2014 a distinct green from the decode dots. */ internal const val PSK_MARKER_COLOR = 0xFF66BB6A.toInt() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarSurfaceState.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarSurfaceState.kt index 51c7deb51..8431efe84 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarSurfaceState.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarSurfaceState.kt @@ -17,9 +17,9 @@ internal data class CarStationMarker( * map frame. Built on the main thread by [QsoStatusScreen], then handed to the * renderer which reads it on whatever thread holds the surface lock. * - * The status text (headline, slot, band, POTA) is carried here too and drawn as - * banners directly on the surface by [CarMapSurfaceRenderer] — the NavigationTemplate - * has no host content card, so nothing is host-rendered. + * The status text (headline, slot, band, activation) is carried here too and drawn + * as banners directly on the surface by [CarMapSurfaceRenderer] — the + * NavigationTemplate has no host content card, so nothing is host-rendered. */ internal data class CarSurfaceState( /** Operator's latitude (from grid), or NaN when unknown. */ @@ -40,6 +40,10 @@ internal data class CarSurfaceState( val slotText: String, /** "14.074 MHz · 20m · FT8". */ val bandText: String, - /** "POTA K-1234 · 3 QSOs", or null when no activation is running. */ - val potaText: String?, + /** + * The activation lines drawn under the band: a POTA and/or ROTA line while + * activating ("POTA K-1234 · 12 QSOs", "ROTA Route 66 · 0 QSOs"), or a single + * session line ("Session · 5 QSOs") when neither is active. Empty draws nothing. + */ + val activationLines: List, ) 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 ed3f308f9..0cdbec2b8 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 @@ -40,6 +40,7 @@ import kotlinx.coroutines.launch import radio.ks3ckc.ft8af.pota.PotaSessionManager import radio.ks3ckc.ft8af.pskreporter.PskReporterClient import radio.ks3ckc.ft8af.pskreporter.WhoHeardMeCache +import radio.ks3ckc.ft8af.rota.RotaTripManager import radio.ks3ckc.ft8af.ui.components.slotTimerState import radio.ks3ckc.ft8af.ui.map.StateLabel import radio.ks3ckc.ft8af.ui.map.UsStateLabels @@ -295,6 +296,10 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec val headline = resolve(status.headline) + (status.snrLabel?.let { " · $it" } ?: "") + val bandRow = Row.Builder().setTitle(status.bandLine) + carDecodesSecondary(vm.mutableFt8MessageList.value?.size ?: 0)?.let { + bandRow.addText(resolve(it)) + } val rows = mutableListOf( Row.Builder() .setTitle(headline) @@ -304,18 +309,54 @@ 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(), + bandRow.build(), ) - // POTA activation line (only when running an activation) - val activation = PotaSessionManager.currentActivation.value - buildCarPotaLine(activation?.parkRefsDisplay, activation?.qsoCount)?.let { - rows.add(Row.Builder().setTitle(resolve(it)).build()) + // Activation dashboard: POTA and/or ROTA rows while activating, otherwise a + // session-summary row (see buildCarActivationRows). + buildActivationPaneRows(vm).forEach { r -> + rows.add( + Row.Builder() + .setTitle(resolve(r.title)) + .apply { r.secondary?.let { addText(resolve(it)) } } + .build(), + ) } return Pane.Builder().apply { rows.take(paneRowLimit(carContext)).forEach { addRow(it) } }.build() } + /** + * Reads the current POTA/ROTA activation and session state and maps it to the + * pane's activation rows. The decision logic lives in the pure + * [buildCarActivationRows]; this just extracts the primitives from the app + * singletons. "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 + * ([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, + ), + ) + } + // ----------------------------------------------------------------------- // Surface rendering // ----------------------------------------------------------------------- @@ -420,7 +461,6 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec partnerLatLng?.longitude ?: Double.NaN, ) - val activation = PotaSessionManager.currentActivation.value return CarSurfaceState( opLat = opLatLng?.latitude ?: Double.NaN, opLon = opLatLng?.longitude ?: Double.NaN, @@ -431,7 +471,10 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec headlineText = resolve(status.headline) + (status.snrLabel?.let { " · $it" } ?: ""), slotText = resolve(status.slotLine) + (partnerLocation?.let { " · $it" } ?: ""), bandText = status.bandLine, - potaText = buildCarPotaLine(activation?.parkRefsDisplay, activation?.qsoCount)?.let { resolve(it) }, + // Same activation/session rows as the pane, drawn as compact single lines + // (titles only — the "to validate" / mileage / last-logged detail stays on + // the pane so the surface overlay doesn't crowd the map). + activationLines = buildActivationPaneRows(vm).map { resolve(it.title) }, ) } diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 66b9e8d52..ef9734858 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -914,6 +914,15 @@ Recent decodes No decodes yet POTA %1$s · %2$d QSOs + %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 000000000..88aea6368 --- /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) + } +} From f75a0e8a298877b4fe9465efcda855d666dd8865 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Sun, 9 Aug 2026 00:39:56 -0700 Subject: [PATCH 2/2] Address Copilot review: use per-cycle decode count for car secondary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit carDecodesSecondary read mutableFt8MessageList.size, which accumulates across cycles when clearDecodesEveryCycle is off (the default) — the "N decodes last cycle" secondary would show a stale, growing count. Use vm.currentMessages (the per-cycle label overlay), which is refreshed each cycle and cleared on a silent slot, so the count reflects the latest cycle and drops to 0 correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0cdbec2b8..8d71c0815 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 @@ -297,7 +297,11 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec val headline = resolve(status.headline) + (status.snrLabel?.let { " · $it" } ?: "") val bandRow = Row.Builder().setTitle(status.bandLine) - carDecodesSecondary(vm.mutableFt8MessageList.value?.size ?: 0)?.let { + // Per-cycle decode count: currentMessages (the label overlay) is refreshed + // every cycle and cleared on a silent slot, so this drops to 0 correctly. + // mutableFt8MessageList accumulates across cycles when clearDecodesEveryCycle + // is off (the default), which would keep a stale "N decodes last cycle". + carDecodesSecondary(vm.currentMessages?.size ?: 0)?.let { bandRow.addText(resolve(it)) } val rows = mutableListOf(