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)) + } +}