diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt
index fb31b09b0..103b2a3d3 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt
@@ -112,6 +112,8 @@ private fun QsoSheetContent(
val isActivated by mainViewModel.ft8TransmitSignal.mutableIsActivated.observeAsState(false)
val toCallsign by mainViewModel.ft8TransmitSignal.mutableToCallsign.observeAsState()
val txFunctionOrder by mainViewModel.ft8TransmitSignal.mutableFunctionOrder.observeAsState(6)
+ // App-wide UTC clock (~1s heartbeat); keeps the "Last heard" value fresh.
+ val nowMillis by mainViewModel.timerSec.observeAsState(UtcTimer.getSystemTime())
val usState = UsStateLookup.stateFromGrid(context, message.maidenGrid)
@@ -146,6 +148,11 @@ private fun QsoSheetContent(
Spacer(modifier = Modifier.height(20.dp))
+ // -- Last heard: relative recency of this decode, above the map --
+ LastHeardRow(utcTimeMillis = message.utcTime, nowMillis = nowMillis)
+
+ Spacer(modifier = Modifier.height(12.dp))
+
// -- Path map: operator grid -> remote grid, with a connecting line.
// Renders nothing (and no trailing spacer) when either grid is unknown.
QsoPathMap(
@@ -813,6 +820,94 @@ private fun CurrentTxBanner(
}
}
+// ---------------------------------------------------------------------------
+// "Last heard" relative time
+// ---------------------------------------------------------------------------
+
+/**
+ * Bucketed relative age of a decode, used for the "Last heard" line above the
+ * map. Keeping the bucketing as a pure sealed result (rather than a formatted
+ * string) lets [computeLastHeard] be unit-tested without Compose/resources and
+ * keeps the "ago" wording localizable in [lastHeardText].
+ */
+internal sealed interface LastHeard {
+ /** No usable timestamp (missing / non-positive). */
+ object Unknown : LastHeard
+
+ /** Under 5 s old, or a future timestamp clamped forward. */
+ object JustNow : LastHeard
+
+ data class Seconds(val value: Long) : LastHeard
+ data class Minutes(val value: Long) : LastHeard
+ data class Hours(val value: Long) : LastHeard
+ data class Days(val value: Long) : LastHeard
+}
+
+/**
+ * Bucket the elapsed time between [utcTimeMillis] (when the station was heard)
+ * and [nowMillis] into a [LastHeard] value. Edge cases handled cleanly:
+ * - a missing/non-positive timestamp -> [LastHeard.Unknown]
+ * - a future timestamp (clock skew) -> [LastHeard.JustNow] (never negative)
+ * - a very old timestamp -> a plain [LastHeard.Days] count
+ *
+ * Thresholds mirror the existing decode-row "ago" formatter (5 s / 60 s / 60 m /
+ * 24 h) so recency reads consistently across the app.
+ */
+internal fun computeLastHeard(utcTimeMillis: Long, nowMillis: Long): LastHeard {
+ if (utcTimeMillis <= 0L) return LastHeard.Unknown
+ val elapsedMs = nowMillis - utcTimeMillis
+ val seconds = (elapsedMs / 1000L).coerceAtLeast(0L)
+ return when {
+ seconds < 5L -> LastHeard.JustNow
+ seconds < 60L -> LastHeard.Seconds(seconds)
+ seconds < 3600L -> LastHeard.Minutes(seconds / 60L)
+ seconds < 86_400L -> LastHeard.Hours(seconds / 3600L)
+ else -> LastHeard.Days(seconds / 86_400L)
+ }
+}
+
+/** Map a [LastHeard] bucket to its localized display string. */
+@Composable
+private fun lastHeardText(lastHeard: LastHeard): String = when (lastHeard) {
+ LastHeard.Unknown -> stringResource(R.string.qso_last_heard_unknown)
+ LastHeard.JustNow -> stringResource(R.string.qso_last_heard_just_now)
+ is LastHeard.Seconds -> stringResource(R.string.qso_last_heard_seconds, lastHeard.value)
+ is LastHeard.Minutes -> stringResource(R.string.qso_last_heard_minutes, lastHeard.value)
+ is LastHeard.Hours -> stringResource(R.string.qso_last_heard_hours, lastHeard.value)
+ is LastHeard.Days -> stringResource(R.string.qso_last_heard_days, lastHeard.value)
+}
+
+/**
+ * "Last heard" row shown above the path map. [nowMillis] is the app's ticking
+ * UTC clock (`MainViewModel.timerSec`), so the value refreshes about once a
+ * second while the sheet is open without a dedicated timer here.
+ */
+@Composable
+private fun LastHeardRow(utcTimeMillis: Long, nowMillis: Long) {
+ val valueText = lastHeardText(computeLastHeard(utcTimeMillis, nowMillis))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.qso_last_heard),
+ color = TextFaint,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.06.sp,
+ )
+ Text(
+ text = valueText,
+ color = TextPrimary,
+ fontFamily = GeistMonoFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ )
+ }
+}
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml
index a644cdd7d..d778b4be0 100644
--- a/ft8af/app/src/main/res/values/strings_compose.xml
+++ b/ft8af/app/src/main/res/values/strings_compose.xml
@@ -201,6 +201,13 @@
message
TX NOW
TX NEXT
+ Last heard
+ --
+ just now
+ %1$ds ago
+ %1$dm ago
+ %1$dh ago
+ %1$dd ago
Stats
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/LastHeardTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/LastHeardTest.kt
new file mode 100644
index 000000000..a4a30c5fe
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/LastHeardTest.kt
@@ -0,0 +1,61 @@
+package radio.ks3ckc.ft8af.ui.decode
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Unit tests for the pure "Last heard" relative-time logic [computeLastHeard]
+ * shown above the map on the QSO Details sheet. Pure math/logic, so no
+ * Robolectric runner is needed.
+ */
+class LastHeardTest {
+
+ private val now = 1_700_000_000_000L // fixed reference "now" in epoch ms
+
+ @Test
+ fun `missing timestamp is Unknown`() {
+ assertThat(computeLastHeard(0L, now)).isEqualTo(LastHeard.Unknown)
+ assertThat(computeLastHeard(-1L, now)).isEqualTo(LastHeard.Unknown)
+ }
+
+ @Test
+ fun `a future timestamp clamps to just now`() {
+ // Heard "in the future" (clock skew) must never read as a negative age.
+ assertThat(computeLastHeard(now + 10_000L, now)).isEqualTo(LastHeard.JustNow)
+ }
+
+ @Test
+ fun `under five seconds is just now`() {
+ assertThat(computeLastHeard(now, now)).isEqualTo(LastHeard.JustNow)
+ assertThat(computeLastHeard(now - 4_999L, now)).isEqualTo(LastHeard.JustNow)
+ }
+
+ @Test
+ fun `seconds bucket covers 5s up to under a minute`() {
+ assertThat(computeLastHeard(now - 5_000L, now)).isEqualTo(LastHeard.Seconds(5))
+ assertThat(computeLastHeard(now - 45_000L, now)).isEqualTo(LastHeard.Seconds(45))
+ assertThat(computeLastHeard(now - 59_999L, now)).isEqualTo(LastHeard.Seconds(59))
+ }
+
+ @Test
+ fun `minutes bucket covers one minute up to under an hour`() {
+ assertThat(computeLastHeard(now - 60_000L, now)).isEqualTo(LastHeard.Minutes(1))
+ assertThat(computeLastHeard(now - 12L * 60_000L, now)).isEqualTo(LastHeard.Minutes(12))
+ assertThat(computeLastHeard(now - 3_599_999L, now)).isEqualTo(LastHeard.Minutes(59))
+ }
+
+ @Test
+ fun `hours bucket covers one hour up to under a day`() {
+ assertThat(computeLastHeard(now - 3_600_000L, now)).isEqualTo(LastHeard.Hours(1))
+ assertThat(computeLastHeard(now - 3L * 3_600_000L, now)).isEqualTo(LastHeard.Hours(3))
+ assertThat(computeLastHeard(now - (86_400_000L - 1L), now)).isEqualTo(LastHeard.Hours(23))
+ }
+
+ @Test
+ fun `days bucket covers a day and beyond, including very old timestamps`() {
+ assertThat(computeLastHeard(now - 86_400_000L, now)).isEqualTo(LastHeard.Days(2 - 1))
+ assertThat(computeLastHeard(now - 2L * 86_400_000L, now)).isEqualTo(LastHeard.Days(2))
+ // Very old timestamp still formats cleanly as a large day count.
+ assertThat(computeLastHeard(now - 500L * 86_400_000L, now)).isEqualTo(LastHeard.Days(500))
+ }
+}