Refactor command handling, enhance tests, and improve discovery logic (#4878)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-22 00:42:27 -05:00
committed by GitHub
parent d136b162a4
commit c38bfc64de
76 changed files with 2220 additions and 1277 deletions

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.util.AlertManager
/**
* Shared composable that observes [AlertManager.currentAlert] and renders a [MeshtasticDialog] when an alert is
* present. This eliminates duplicated alert-rendering boilerplate across Android and Desktop host shells.
*
* Usage: Place `AlertHost(alertManager)` once in the top-level composable of each platform host.
*/
@Composable
fun AlertHost(alertManager: AlertManager) {
val alertDialogState by alertManager.currentAlert.collectAsStateWithLifecycle()
alertDialogState?.let { state ->
MeshtasticDialog(
title = state.title,
titleRes = state.titleRes,
message = state.message,
messageRes = state.messageRes,
html = state.html,
icon = state.icon,
text = state.composableMessage?.let { msg -> { msg.Content() } },
confirmText = state.confirmText,
confirmTextRes = state.confirmTextRes,
onConfirm = state.onConfirm,
dismissText = state.dismissText,
dismissTextRes = state.dismissTextRes,
onDismiss = state.onDismiss,
choices = state.choices,
dismissable = state.dismissable,
)
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
/**
* Shared placeholder screen for desktop/JVM feature stubs that are not yet implemented. Displays a centered label in
* [MaterialTheme.typography.headlineMedium].
*/
@Composable
fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/**
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
* connected and requests are pending.
*
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
*/
@Composable
fun SharedDialogs(
connectionState: ConnectionState,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onDismissSharedContact: () -> Unit,
onDismissChannelSet: () -> Unit,
) {
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
}
}

View File

@@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
@@ -27,6 +29,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@KoinViewModel
@@ -46,6 +49,28 @@ class ConnectionsViewModel(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
/**
* Filtered [ourNodeInfo] that only emits when display-relevant fields change, preventing continuous recomposition
* from lastHeard/snr updates.
*/
val ourNodeForDisplay: StateFlow<Node?> =
nodeRepository.ourNodeInfo
.distinctUntilChanged { old, new ->
old?.num == new?.num &&
old?.user == new?.user &&
old?.batteryLevel == new?.batteryLevel &&
old?.voltage == new?.voltage &&
old?.metadata?.firmware_version == new?.metadata?.firmware_version
}
.stateInWhileSubscribed(initialValue = nodeRepository.ourNodeInfo.value)
/** Whether the LoRa region is UNSET and needs to be configured. */
val regionUnset: StateFlow<Boolean> =
radioConfigRepository.localConfigFlow
.map { it.lora?.region == Config.LoRaConfig.RegionCode.UNSET }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = false)
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

View File

@@ -79,7 +79,7 @@ class UIViewModel(
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
private val alertManager: AlertManager,
val alertManager: AlertManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
@@ -121,8 +121,6 @@ class UIViewModel(
_scrollToTopEventFlow.tryEmit(event)
}
val currentAlert = alertManager.currentAlert
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,

View File

@@ -16,17 +16,17 @@
*/
package org.meshtastic.core.ui.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class AlertManagerTest {
private val alertManager = AlertManager()
@Test
fun `showAlert updates currentAlert flow`() {
fun showAlert_updates_currentAlert_flow() {
val title = "Test Title"
val message = "Test Message"
@@ -34,12 +34,12 @@ class AlertManagerTest {
val alertData = alertManager.currentAlert.value
assertNotNull(alertData)
assertEquals(title, alertData?.title)
assertEquals(message, alertData?.message)
assertEquals(title, alertData.title)
assertEquals(message, alertData.message)
}
@Test
fun `dismissAlert clears currentAlert flow`() {
fun dismissAlert_clears_currentAlert_flow() {
alertManager.showAlert(title = "Title")
assertNotNull(alertManager.currentAlert.value)
@@ -48,7 +48,7 @@ class AlertManagerTest {
}
@Test
fun `onConfirm triggers and dismisses alert`() {
fun onConfirm_triggers_and_dismisses_alert() {
var confirmClicked = false
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
@@ -59,7 +59,7 @@ class AlertManagerTest {
}
@Test
fun `onDismiss triggers and dismisses alert`() {
fun onDismiss_triggers_and_dismisses_alert() {
var dismissClicked = false
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })