From 5665cb10eab8c19e49bde283f2c6832a8c359220 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Mon, 13 Jul 2026 15:12:33 +0000 Subject: [PATCH] Add "Last heard" relative-time line above map on QSO Details sheet Show a human-readable recency indicator ("just now", "45s ago", "12m ago", "3h ago", "2d ago") above the path map on the QSO Details bottom sheet, so operators get immediate recency context without reading a raw timestamp. The bucketing is extracted into a pure, unit-tested computeLastHeard() returning a LastHeard sealed type (Compose-free, localizable), with a thin LastHeardRow wrapper that maps buckets to localized strings. Edge cases are handled cleanly: missing/non-positive timestamp -> "--", future timestamp (clock skew) clamps to "just now", very old -> plain day count. The value refreshes ~1s via the app's existing MainViewModel.timerSec UTC heartbeat while the sheet is open. Thresholds mirror the decode-row "ago" formatter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt | 95 +++++++++++++++++++ .../src/main/res/values/strings_compose.xml | 7 ++ .../ks3ckc/ft8af/ui/decode/LastHeardTest.kt | 61 ++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/decode/LastHeardTest.kt 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)) + } +}