Skip to content
Open
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
95 changes: 95 additions & 0 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/decode/QsoSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment on lines +115 to +116

val usState = UsStateLookup.stateFromGrid(context, message.maidenGrid)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
*/
Comment on lines +853 to +855
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
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions ft8af/app/src/main/res/values/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@
<string name="qso_live_step_fallback">message</string>
<string name="qso_tx_now">TX NOW</string>
<string name="qso_tx_next">TX NEXT</string>
<string name="qso_last_heard">Last heard</string>
<string name="qso_last_heard_unknown">--</string>
<string name="qso_last_heard_just_now">just now</string>
<string name="qso_last_heard_seconds">%1$ds ago</string>
<string name="qso_last_heard_minutes">%1$dm ago</string>
<string name="qso_last_heard_hours">%1$dh ago</string>
<string name="qso_last_heard_days">%1$dd ago</string>

<!-- ===== Logbook ===== -->
<string name="log_tab_stats">Stats</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading