diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt index 9244175e..0e40fed6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -58,6 +59,7 @@ import com.k1af.ft8af.rigs.InstructionSet import com.k1af.ft8af.ui.ToastMessage import com.k1af.ft8af.x6100.X6100Radio import com.k1af.ft8af.x6100.XieguRadioFactory +import kotlinx.coroutines.delay import radio.ks3ckc.ft8af.theme.* // ───────────────────────────────────────────────────────────────────── @@ -251,25 +253,25 @@ fun FlexRadioPickerDialog( var ipText by remember { mutableStateOf("") } val discoveredRadios = remember { mutableStateListOf() } - // Subscribe to FlexRadioFactory discovery events - DisposableEffect(Unit) { + // Poll discovery while the dialog is open so radios found *after* opening + // (discovery typically takes a few seconds) appear live — no need to close + // and reopen the picker. The previous one-shot event listener only fired on + // the first add, so a radio that surfaced moments later went unseen until + // the dialog was reopened. We rebuild the list only when the discovered set + // actually changes (see FlexPickerLogic) to avoid recomposing / resetting + // the scroll position every tick. + LaunchedEffect(Unit) { val factory = FlexRadioFactory.getInstance() - // Seed with any already-discovered radios - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - - val listener = object : FlexRadioFactory.OnFlexRadioEvents { - override fun OnFlexRadioAdded(flexRadio: FlexRadio) { - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - } - override fun OnFlexRadioInvalid(flexRadio: FlexRadio) { + while (true) { + val latest = factory.flexRadios.toList() + val displayedKeys = discoveredRadios.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + val latestKeys = latest.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + if (FlexPickerLogic.listChanged(displayedKeys, latestKeys)) { discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) + discoveredRadios.addAll(latest) } + delay(1000) } - factory.setOnFlexRadioEvents(listener) - onDispose { factory.setOnFlexRadioEvents(null) } } Dialog(onDismissRequest = onDismiss) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt new file mode 100644 index 00000000..407fabbf --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt @@ -0,0 +1,33 @@ +package radio.ks3ckc.ft8af.ui.settings + +/** + * Pure helpers for the FlexRadio picker's live-discovery list. + * + * The picker polls [com.k1af.ft8af.flex.FlexRadioFactory] once a second while + * open. These functions keep that polling cheap and stable: derive a stable + * identity per radio, and only rebuild the displayed list when the discovered + * set actually changed (so the list doesn't recompose / reset its scroll every + * tick). Kept free of Android/Compose types so it's unit-testable. + */ +internal object FlexPickerLogic { + + /** + * Stable identity for a discovered radio. Prefers the serial number; falls + * back to the IP when the serial is missing/blank. Trimmed so incidental + * whitespace doesn't read as a change. + */ + fun identityKey(serial: String?, ip: String?): String { + val s = serial?.trim().orEmpty() + if (s.isNotEmpty()) return s + return ip?.trim().orEmpty() + } + + /** + * Whether the latest discovery snapshot differs from what's displayed, + * compared by identity key in order. When false, the caller skips the + * (otherwise per-second) list rebuild. + */ + fun listChanged(displayedKeys: List, latestKeys: List): Boolean { + return displayedKeys != latestKeys + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt new file mode 100644 index 00000000..20f7a2f1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt @@ -0,0 +1,52 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [FlexPickerLogic], the live-discovery list helpers behind the + * FlexRadio picker polling fix. + */ +class FlexPickerLogicTest { + + @Test + fun identityKey_prefersSerial() { + assertThat(FlexPickerLogic.identityKey("0123-4567-89", "192.168.86.159")) + .isEqualTo("0123-4567-89") + } + + @Test + fun identityKey_fallsBackToIpWhenSerialBlank() { + assertThat(FlexPickerLogic.identityKey(" ", "192.168.86.159")) + .isEqualTo("192.168.86.159") + assertThat(FlexPickerLogic.identityKey(null, "192.168.86.159")) + .isEqualTo("192.168.86.159") + } + + @Test + fun identityKey_trimsWhitespace() { + assertThat(FlexPickerLogic.identityKey(" FLEX-1 ", null)).isEqualTo("FLEX-1") + } + + @Test + fun identityKey_emptyWhenNothingKnown() { + assertThat(FlexPickerLogic.identityKey(null, null)).isEmpty() + } + + @Test + fun listChanged_falseWhenIdentical() { + // The common per-tick case: nothing new discovered → no rebuild. + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a", "b"))).isFalse() + } + + @Test + fun listChanged_trueWhenRadioAppears() { + // A radio surfaces after the dialog is already open — must update live. + assertThat(FlexPickerLogic.listChanged(emptyList(), listOf("a"))).isTrue() + } + + @Test + fun listChanged_trueWhenRadioDropsOff() { + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a"))).isTrue() + } +}