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..752d0d9fa 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
@@ -146,6 +146,11 @@ private fun QsoSheetContent(
Spacer(modifier = Modifier.height(20.dp))
+ // -- Last heard: relative recency of this decode, above the map --
+ LastHeardRow(utcTimeMillis = message.utcTime, mainViewModel = mainViewModel)
+
+ 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 +818,99 @@ 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
+ *
+ * The bucket *thresholds* (5 s / 60 s / 60 m / 24 h) mirror the existing
+ * decode-row "ago" formatter so recency reads consistently across the app; the
+ * displayed wording is independent (e.g. this bucket says "just now" where the
+ * decode row says "now") and lives in [lastHeardText].
+ */
+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. The app's ticking UTC clock
+ * (`MainViewModel.timerSec`) is observed *here* rather than in the parent sheet:
+ * it changes ~1×/s, and the sheet also hosts `QsoPathMap`, which redraws on every
+ * recompose — observing the tick at the sheet level would repaint the whole map
+ * each second, so the observation is scoped to just this row.
+ */
+@Composable
+private fun LastHeardRow(utcTimeMillis: Long, mainViewModel: MainViewModel) {
+ val nowMillis by mainViewModel.timerSec.observeAsState(UtcTimer.getSystemTime())
+ 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..37c6a7bcc
--- /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(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))
+ }
+}