Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarQsoStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<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

/** ARGB colour for "who heard me" PSK markers \u2014 a distinct green from the decode dots. */
internal const val PSK_MARKER_COLOR = 0xFF66BB6A.toInt()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<String>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -295,6 +296,14 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec

val headline = resolve(status.headline) +
(status.snrLabel?.let { " · $it" } ?: "")
val bandRow = Row.Builder().setTitle(status.bandLine)
// 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(
Row.Builder()
.setTitle(headline)
Expand All @@ -304,18 +313,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<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,
),
)
}

// -----------------------------------------------------------------------
// Surface rendering
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -420,7 +465,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,
Expand All @@ -431,7 +475,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) },
)
}

Expand Down
9 changes: 9 additions & 0 deletions ft8af/app/src/main/res/values/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +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_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