Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 115 additions & 14 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<CarPaneRow> {
val rows = mutableListOf<CarPaneRow>()
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?)

Expand Down
61 changes: 49 additions & 12 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<CarPaneRow> {
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())

Expand Down
10 changes: 9 additions & 1 deletion ft8af/app/src/main/res/values/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,15 @@
<string name="car_decodes_title">Recent decodes</string>
<string name="car_no_decodes">No decodes yet</string>
<string name="car_pota_line">POTA %1$s · %2$d QSOs</string>
<string name="car_rota_line">ROTA %1$s · %2$d QSOs · %3$s mi</string>
<string name="car_pota_to_validate">%1$d more to validate the activation</string>
<string name="car_pota_validated">Activation validated</string>
<string name="car_rota_line">ROTA %1$s · %2$d QSOs</string>
<string name="car_rota_miles">%1$s mi driven this activation</string>
<string name="car_decodes_last_cycle">%1$d decodes last cycle</string>
<string name="car_session_line">Session · %1$d QSOs</string>
<string name="car_session_last">Last logged %1$s · %2$s · %3$d min</string>
<string name="car_session_last_noband">Last logged %1$s · %2$d min</string>
<string name="car_session_none">No QSOs logged yet</string>

<!-- ROTA (Roads On The Air) trip mode -->
<string name="rota_title">Roads On The Air (ROTA)</string>
Expand Down
Loading
Loading