mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-28 18:52:42 -04:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user