feat(connections): Connecting state refactor (#3722)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-11-17 15:15:22 -06:00
committed by GitHub
parent 12ccb34553
commit 73d933fe14
22 changed files with 379 additions and 263 deletions

View File

@@ -33,7 +33,6 @@
<ID>FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt</ID>
<ID>FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt</ID>
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", byte)</ID>
<ID>LambdaParameterEventTrailing:Channel.kt$onConfirm</ID>
<ID>LambdaParameterInRestartableEffect:Channel.kt$onConfirm</ID>
<ID>LargeClass:MeshService.kt$MeshService : Service</ID>
@@ -67,9 +66,8 @@
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord."</ID>
<ID>MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService.&lt;no name provided&gt;$"sendData dest=${p.to}, id=${p.id} &lt;- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})"</ID>
<ID>MaxLineLength:MeshService.kt$MeshService.&lt;no name provided&gt;$"sendData dest=${p.to}, id=${p.id} &lt;- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"</ID>
<ID>MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"</ID>
@@ -120,7 +118,6 @@
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception</ID>
<ID>TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception</ID>

View File

@@ -77,7 +77,7 @@ constructor(
private val analytics: PlatformAnalytics,
) {
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow<ByteArray>()
@@ -248,13 +248,13 @@ constructor(
}
fun onConnect() {
if (_connectionState.value != ConnectionState.CONNECTED) {
broadcastConnectionChanged(ConnectionState.CONNECTED)
if (_connectionState.value != ConnectionState.Connected) {
broadcastConnectionChanged(ConnectionState.Connected)
}
}
fun onDisconnect(isPermanent: Boolean) {
val newTargetState = if (isPermanent) ConnectionState.DISCONNECTED else ConnectionState.DEVICE_SLEEP
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
@@ -319,7 +319,7 @@ constructor(
* @return true if the device changed, false if no change
*/
private fun setBondedDeviceAddress(address: String?): Boolean =
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.CONNECTED) {
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
Timber.w("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device")
false
} else {

View File

@@ -94,6 +94,7 @@ import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
@@ -1426,98 +1427,110 @@ class MeshService : Service() {
// Called when we gain/lose connection to our radio
@Suppress("CyclomaticComplexMethod")
private fun onConnectionChanged(c: ConnectionState) {
Timber.d("onConnectionChanged: ${connectionStateHolder.getState()} -> $c")
// Perform all the steps needed once we start waiting for device sleep to complete
fun startDeviceSleep() {
packetHandler.stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
if (connectTimeMsec != 0L) {
val now = System.currentTimeMillis()
connectTimeMsec = 0L
analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
}
// Have our timeout fire in the appropriate number of seconds
sleepTimeout =
serviceScope.handledLaunch {
try {
// If we have a valid timeout, wait that long (+30 seconds) otherwise, just
// wait 30 seconds
val timeout = (localConfig.power?.lsSecs ?: 0) + 30
Timber.d("Waiting for sleeping device, timeout=$timeout secs")
delay(timeout * 1000L)
Timber.w("Device timeout out, setting disconnected")
onConnectionChanged(ConnectionState.DISCONNECTED)
} catch (_: CancellationException) {
Timber.d("device sleep timeout cancelled")
}
}
// broadcast an intent with our new connection state
serviceBroadcasts.broadcastConnection()
}
fun startDisconnect() {
Timber.d("Starting disconnect")
packetHandler.stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
analytics.track("num_nodes", DataPair("num_nodes", numNodes))
// broadcast an intent with our new connection state
serviceBroadcasts.broadcastConnection()
}
fun startConnect() {
Timber.d("Starting connect")
historyLog {
val address = meshPrefs.deviceAddress ?: "null"
"onReconnect transport=${currentTransport()} node=$address"
}
try {
connectTimeMsec = System.currentTimeMillis()
startConfigOnly()
} catch (ex: InvalidProtocolBufferException) {
Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
} catch (ex: RadioNotConnectedException) {
Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}")
} catch (ex: RemoteException) {
connectionStateHolder.setState(ConnectionState.DEVICE_SLEEP)
startDeviceSleep()
throw ex
}
}
if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
Timber.d("onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c")
// Cancel any existing timeouts
sleepTimeout?.let {
it.cancel()
sleepTimeout = null
}
sleepTimeout?.cancel()
sleepTimeout = null
connectionStateHolder.setState(c)
when (c) {
ConnectionState.CONNECTED -> startConnect()
ConnectionState.DEVICE_SLEEP -> startDeviceSleep()
ConnectionState.DISCONNECTED -> startDisconnect()
is ConnectionState.Connecting -> {
connectionStateHolder.setState(ConnectionState.Connecting)
}
is ConnectionState.Connected -> {
handleConnected()
}
is ConnectionState.DeviceSleep -> {
handleDeviceSleep()
}
is ConnectionState.Disconnected -> {
handleDisconnected()
}
}
updateServiceStatusNotification()
}
private fun handleDisconnected() {
connectionStateHolder.setState(ConnectionState.Disconnected)
Timber.d("Starting disconnect")
packetHandler.stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
analytics.track("num_nodes", DataPair("num_nodes", numNodes))
// broadcast an intent with our new connection state
serviceBroadcasts.broadcastConnection()
}
private fun handleDeviceSleep() {
connectionStateHolder.setState(ConnectionState.DeviceSleep)
packetHandler.stopPacketQueue()
stopLocationRequests()
stopMqttClientProxy()
if (connectTimeMsec != 0L) {
val now = System.currentTimeMillis()
connectTimeMsec = 0L
analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
}
updateServiceStatusNotification()
// Have our timeout fire in the appropriate number of seconds
sleepTimeout =
serviceScope.handledLaunch {
try {
// If we have a valid timeout, wait that long (+30 seconds) otherwise, just
// wait 30 seconds
val timeout = (localConfig.power?.lsSecs ?: 0) + 30
Timber.d("Waiting for sleeping device, timeout=$timeout secs")
delay(timeout * 1000L)
Timber.w("Device timeout out, setting disconnected")
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Timber.d("device sleep timeout cancelled")
}
}
// broadcast an intent with our new connection state
serviceBroadcasts.broadcastConnection()
}
private fun handleConnected() {
connectionStateHolder.setState(ConnectionState.Connecting)
serviceBroadcasts.broadcastConnection()
Timber.d("Starting connect")
historyLog {
val address = meshPrefs.deviceAddress ?: "null"
"onReconnect transport=${currentTransport()} node=$address"
}
try {
connectTimeMsec = System.currentTimeMillis()
startConfigOnly()
} catch (ex: InvalidProtocolBufferException) {
Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
} catch (ex: RadioNotConnectedException) {
Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}")
} catch (ex: RemoteException) {
onConnectionChanged(ConnectionState.DeviceSleep)
throw ex
}
}
private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
val notificationSummary =
when (connectionStateHolder.getState()) {
ConnectionState.CONNECTED -> getString(Res.string.connected_count).format(numOnlineNodes)
when (connectionStateHolder.connectionState.value) {
is ConnectionState.Connected -> getString(Res.string.connected_count).format(numOnlineNodes)
ConnectionState.DISCONNECTED -> getString(Res.string.disconnected)
ConnectionState.DEVICE_SLEEP -> getString(Res.string.device_sleeping)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
}
return serviceNotifications.updateServiceStateNotification(
summaryString = notificationSummary,
@@ -1533,15 +1546,16 @@ class MeshService : Service() {
val effectiveState =
when (newState) {
ConnectionState.CONNECTED -> ConnectionState.CONNECTED
ConnectionState.DEVICE_SLEEP ->
is ConnectionState.Connected -> ConnectionState.Connected
is ConnectionState.DeviceSleep ->
if (lsEnabled) {
ConnectionState.DEVICE_SLEEP
ConnectionState.DeviceSleep
} else {
ConnectionState.DISCONNECTED
ConnectionState.Disconnected
}
ConnectionState.DISCONNECTED -> ConnectionState.DISCONNECTED
is ConnectionState.Connecting -> ConnectionState.Connecting
is ConnectionState.Disconnected -> ConnectionState.Disconnected
}
onConnectionChanged(effectiveState)
}
@@ -1972,7 +1986,6 @@ class MeshService : Service() {
private fun onHasSettings() {
processQueuedPackets()
startMqttClientProxy()
serviceBroadcasts.broadcastConnection()
sendAnalytics()
reportConnection()
historyLog {
@@ -2055,6 +2068,9 @@ class MeshService : Service() {
flushEarlyReceivedPackets("node_info_complete")
sendAnalytics()
onHasSettings()
connectionStateHolder.setState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
updateServiceStatusNotification()
}
}
@@ -2323,7 +2339,7 @@ class MeshService : Service() {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes!!
Timber.i(
"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})",
"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})",
)
if (p.dataType == 0) throw Exception("Port numbers must be non-zero!")
if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) {
@@ -2332,7 +2348,7 @@ class MeshService : Service() {
} else {
p.status = MessageStatus.QUEUED
}
if (connectionStateHolder.getState() == ConnectionState.CONNECTED) {
if (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
try {
sendNow(p)
} catch (ex: Exception) {
@@ -2450,7 +2466,7 @@ class MeshService : Service() {
}
override fun connectionState(): String = toRemoteExceptions {
val r = connectionStateHolder.getState()
val r = connectionStateHolder.connectionState.value
Timber.i("in connectionState=$r")
r.toString()
}

View File

@@ -75,7 +75,7 @@ constructor(
/** Broadcast our current connection status */
fun broadcastConnection() {
val connectionState = connectionStateHolder.getState()
val connectionState = connectionStateHolder.connectionState.value
val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString())
serviceRepository.setConnectionState(connectionState)
explicitBroadcast(intent)

View File

@@ -17,17 +17,18 @@
package com.geeksville.mesh.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.service.ConnectionState
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MeshServiceConnectionStateHolder @Inject constructor() {
private var connectionState = ConnectionState.DISCONNECTED
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState = _connectionState.asStateFlow()
fun setState(state: ConnectionState) {
connectionState = state
_connectionState.value = state
}
fun getState() = connectionState
}

View File

@@ -135,7 +135,7 @@ constructor(
queueJob =
scope.handledLaunch {
Timber.d("packet queueJob started")
while (connectionStateHolder.getState() == ConnectionState.CONNECTED) {
while (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
// take the first packet from the queue head
val packet = queuedPackets.poll() ?: break
try {
@@ -181,7 +181,9 @@ constructor(
val future = CompletableFuture<Boolean>()
queueResponse[packet.id] = future
try {
if (connectionStateHolder.getState() != ConnectionState.CONNECTED) throw RadioNotConnectedException()
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio.newBuilder().apply { this.packet = packet })
} catch (ex: Exception) {
Timber.e(ex, "sendToRadio error: ${ex.message}")

View File

@@ -114,6 +114,7 @@ import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.conversations
import org.meshtastic.core.strings.device_sleeping
@@ -170,13 +171,13 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(connectionState, notificationPermissionState) {
if (connectionState == ConnectionState.CONNECTED && !notificationPermissionState.status.isGranted) {
if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) {
notificationPermissionState.launchPermissionRequest()
}
}
}
if (connectionState == ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
}
@@ -297,10 +298,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.CONNECTED -> stringResource(Res.string.connected)
ConnectionState.DEVICE_SLEEP ->
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.DISCONNECTED -> stringResource(Res.string.disconnected)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
@@ -447,7 +449,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
val latestStableFirmwareRelease by
viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
LaunchedEffect(connectionState, firmwareEdition) {
if (connectionState == ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.Connected) {
firmwareEdition?.let { edition ->
Timber.d("FirmwareEdition: ${edition.name}")
when (edition) {
@@ -465,7 +467,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
// Check if the device is running an old app version or firmware version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.CONNECTED) {
if (connectionState == ConnectionState.Connected) {
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
if (isOld) {

View File

@@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -68,6 +72,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connected_device
import org.meshtastic.core.strings.connected_sleeping
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.not_connected
@@ -93,7 +98,7 @@ fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_C
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
* displays connection status.
*/
@OptIn(ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder")
@Composable
fun ConnectionsScreen(
@@ -109,7 +114,7 @@ fun ConnectionsScreen(
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by
connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.DISCONNECTED)
connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.Disconnected)
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
@@ -157,12 +162,13 @@ fun ConnectionsScreen(
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
ConnectionState.CONNECTED -> {
ConnectionState.Connected -> {
if (regionUnset) Res.string.must_set_region else Res.string.connected
}
ConnectionState.Connecting -> Res.string.connecting
ConnectionState.DISCONNECTED -> Res.string.not_connected
ConnectionState.DEVICE_SLEEP -> Res.string.connected_sleeping
ConnectionState.Disconnected -> Res.string.not_connected
ConnectionState.DeviceSleep -> Res.string.connected_sleeping
}.let { scanModel.setErrorText(getString(it)) }
}
@@ -189,6 +195,11 @@ fun ConnectionsScreen(
.padding(paddingValues)
.padding(16.dp),
) {
AnimatedVisibility(visible = connectionState == ConnectionState.Connecting) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp))
}
}
AnimatedVisibility(
visible = connectionState.isConnected(),
modifier = Modifier.padding(bottom = 16.dp),
@@ -288,12 +299,7 @@ fun ConnectionsScreen(
}
Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
modifier = Modifier.fillMaxWidth(),
fontSize = 10.sp,
textAlign = TextAlign.End,
)
Text(text = scanStatusText.orEmpty(), fontSize = 10.sp, textAlign = TextAlign.End)
}
}
}

View File

@@ -32,7 +32,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -64,7 +65,6 @@ import org.meshtastic.core.strings.permission_missing
import org.meshtastic.core.strings.permission_missing_31
import org.meshtastic.core.strings.scan
import org.meshtastic.core.strings.scanning_bluetooth
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.showToast
/**
@@ -72,11 +72,13 @@ import org.meshtastic.core.ui.util.showToast
* permissions using `accompanist-permissions`.
*
* @param connectionState The current connection state of the MeshService.
* @param btDevices List of discovered BLE devices.
* @param bondedDevices List of discovered BLE devices.
* @param availableDevices
* @param selectedDevice The full address of the currently selected device.
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
* @param bluetoothEnabled
*/
@OptIn(ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun BLEDevices(
@@ -159,7 +161,9 @@ fun BLEDevices(
}
if (isScanning) {
CircularProgressIndicator(modifier = Modifier.size(24.dp).align(Alignment.Center))
CircularWavyProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
)
}
}
}
@@ -177,14 +181,14 @@ fun BLEDevices(
actionButton = scanButton,
)
} else {
bondedDevices.Section(
bondedDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_paired_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
availableDevices.Section(
availableDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_available_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
@@ -226,25 +230,3 @@ private fun checkPermissionsAndScan(
permissionsState.launchMultiplePermissionRequest()
}
}
@Composable
private fun List<DeviceListEntry>.Section(
title: String,
connectionState: ConnectionState,
selectedDevice: String,
onSelect: (DeviceListEntry) -> Unit,
) {
if (isNotEmpty()) {
TitledCard(title = title) {
forEach { device ->
val connected = connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice
DeviceListItem(
connected = connected,
device = device,
onSelect = { onSelect(device) },
modifier = Modifier,
)
}
}
}
}

View File

@@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.Snooze
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
@@ -28,7 +29,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
@@ -41,31 +44,15 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Composable
fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: ConnectionState, deviceType: DeviceType?) {
val tint =
when (connectionState) {
ConnectionState.DISCONNECTED -> colorScheme.StatusRed
ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow
else -> colorScheme.StatusGreen
}
val tint = getTint(connectionState)
val (backgroundIcon, connectionTypeIcon) =
when (connectionState) {
ConnectionState.DISCONNECTED -> MeshtasticIcons.NoDevice to null
ConnectionState.DEVICE_SLEEP -> MeshtasticIcons.Device to Icons.Rounded.Snooze
else ->
MeshtasticIcons.Device to
when (deviceType) {
DeviceType.BLE -> Icons.Rounded.Bluetooth
DeviceType.TCP -> Icons.Rounded.Wifi
DeviceType.USB -> Icons.Rounded.Usb
else -> null
}
}
val (backgroundIcon, connectionTypeIcon) = getIconPair(deviceType = deviceType, connectionState = connectionState)
val foregroundPainter = connectionTypeIcon?.let { rememberVectorPainter(it) }
@@ -85,11 +72,40 @@ fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: Connectio
)
}
@Composable
private fun getTint(connectionState: ConnectionState): Color = when (connectionState) {
ConnectionState.Connecting -> colorScheme.StatusOrange
ConnectionState.Disconnected -> colorScheme.StatusRed
ConnectionState.DeviceSleep -> colorScheme.StatusYellow
else -> colorScheme.StatusGreen
}
class ConnectionStateProvider : PreviewParameterProvider<ConnectionState> {
override val values: Sequence<ConnectionState> =
sequenceOf(ConnectionState.CONNECTED, ConnectionState.DEVICE_SLEEP, ConnectionState.DISCONNECTED)
sequenceOf(
ConnectionState.Connected,
ConnectionState.Connecting,
ConnectionState.DeviceSleep,
ConnectionState.Disconnected,
)
}
@Composable
fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair<ImageVector, ImageVector?> =
when (connectionState) {
ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null
ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze
ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached
else ->
MeshtasticIcons.Device to
when (deviceType) {
DeviceType.BLE -> Icons.Rounded.Bluetooth
DeviceType.TCP -> Icons.Rounded.Wifi
DeviceType.USB -> Icons.Rounded.Usb
else -> null
}
}
class DeviceTypeProvider : PreviewParameterProvider<DeviceType> {
override val values: Sequence<DeviceType> = sequenceOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
}
@@ -105,5 +121,5 @@ private fun ConnectionsNavIconPreviewConnectionStates(
@Preview(showBackground = true)
@Composable
private fun ConnectionsNavIconPreviewDeviceTypes(@PreviewParameter(DeviceTypeProvider::class) deviceType: DeviceType) {
ConnectionsNavIcon(connectionState = ConnectionState.CONNECTED, deviceType = deviceType)
ConnectionsNavIcon(connectionState = ConnectionState.Connected, deviceType = deviceType)
}

View File

@@ -19,12 +19,16 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.BluetoothConnected
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
@@ -33,25 +37,36 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () -> Unit, modifier: Modifier = Modifier) {
fun DeviceListItem(
connectionState: ConnectionState,
device: DeviceListEntry,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val icon =
when (device) {
is DeviceListEntry.Ble ->
if (connected) {
if (connectionState.isConnected()) {
Icons.Rounded.BluetoothConnected
} else if (connectionState.isConnecting()) {
Icons.AutoMirrored.Rounded.BluetoothSearching
} else {
Icons.Rounded.Bluetooth
}
is DeviceListEntry.Usb -> Icons.Rounded.Usb
is DeviceListEntry.Tcp -> Icons.Rounded.Wifi
is DeviceListEntry.Mock -> Icons.Rounded.Add
@@ -80,7 +95,13 @@ fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () ->
Text(device.address)
}
},
trailingContent = { RadioButton(selected = connected, onClick = null) },
trailingContent = {
if (connectionState.isConnecting()) {
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
} else {
RadioButton(selected = connectionState.isConnected(), onClick = null)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.geeksville.mesh.model.DeviceListEntry
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.TitledCard
@Composable
fun List<DeviceListEntry>.DeviceListSection(
title: String,
connectionState: ConnectionState,
selectedDevice: String,
onSelect: (DeviceListEntry) -> Unit,
modifier: Modifier = Modifier,
) {
if (isNotEmpty()) {
TitledCard(title = title, modifier = modifier) {
forEach { device ->
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected,
device = device,
onSelect = { onSelect(device) },
modifier = Modifier.Companion,
)
}
}
}
}

View File

@@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -67,7 +66,6 @@ import org.meshtastic.core.strings.ip_address
import org.meshtastic.core.strings.ip_port
import org.meshtastic.core.strings.no_network_devices
import org.meshtastic.core.strings.recent_network_devices
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@@ -130,39 +128,21 @@ fun NetworkDevices(
else -> {
if (recentNetworkDevices.isNotEmpty()) {
TitledCard(title = stringResource(Res.string.recent_network_devices)) {
recentNetworkDevices.forEach { device ->
DeviceListItem(
connected =
connectionState == ConnectionState.CONNECTED &&
device.fullAddress == selectedDevice,
device = device,
onSelect = { scanModel.onSelected(device) },
modifier =
Modifier.combinedClickable(
onClick = { scanModel.onSelected(device) },
onLongClick = {
deviceToDelete = device
showDeleteDialog = true
},
),
)
}
}
recentNetworkDevices.DeviceListSection(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
if (discoveredNetworkDevices.isNotEmpty()) {
TitledCard(title = stringResource(Res.string.discovered_network_devices)) {
discoveredNetworkDevices.forEach { device ->
DeviceListItem(
connected =
connectionState == ConnectionState.CONNECTED &&
device.fullAddress == selectedDevice,
device = device,
onSelect = { scanModel.onSelected(device) },
)
}
}
discoveredNetworkDevices.DeviceListSection(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
addButton()

View File

@@ -20,7 +20,6 @@ package com.geeksville.mesh.ui.connections.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.UsbOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
@@ -28,7 +27,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.no_usb_devices
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.strings.usb_devices
import org.meshtastic.core.ui.theme.AppTheme
@Composable
@@ -38,7 +37,7 @@ fun UsbDevices(
selectedDevice: String,
scanModel: BTScanModel,
) {
UsbDevices(
UsbDevicesInternal(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
@@ -47,7 +46,7 @@ fun UsbDevices(
}
@Composable
private fun UsbDevices(
private fun UsbDevicesInternal(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
@@ -58,17 +57,12 @@ private fun UsbDevices(
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices))
else ->
TitledCard(title = null) {
usbDevices.forEach { device ->
DeviceListItem(
connected =
connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice,
device = device,
onSelect = { onDeviceSelected(device) },
modifier = Modifier,
)
}
}
usbDevices.DeviceListSection(
title = stringResource(Res.string.usb_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = onDeviceSelected,
)
}
}
@@ -76,8 +70,8 @@ private fun UsbDevices(
@Composable
private fun UsbDevicesPreview() {
AppTheme {
UsbDevices(
connectionState = ConnectionState.CONNECTED,
UsbDevicesInternal(
connectionState = ConnectionState.Connected,
usbDevices = emptyList(),
selectedDevice = "",
onDeviceSelected = {},

View File

@@ -153,7 +153,7 @@ fun ChannelScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val enabled = connectionState == ConnectionState.CONNECTED && !viewModel.isManaged
val enabled = connectionState == ConnectionState.Connected && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }

View File

@@ -17,17 +17,24 @@
package org.meshtastic.core.service
enum class ConnectionState {
sealed class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
DISCONNECTED,
data object Disconnected : ConnectionState()
/** We are currently attempting to connect to the device. */
data object Connecting : ConnectionState()
/** We are connected to the device and communicating normally. */
CONNECTED,
data object Connected : ConnectionState()
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
DEVICE_SLEEP,
data object DeviceSleep : ConnectionState()
;
fun isConnected() = this == Connected
fun isConnected() = this != DISCONNECTED
fun isConnecting() = this == Connecting
fun isDisconnected() = this == Disconnected
fun isDeviceSleep() = this == DeviceSleep
}

View File

@@ -41,7 +41,7 @@ class ServiceRepository @Inject constructor() {
}
// Connection state to our radio device
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState>
get() = _connectionState
@@ -81,7 +81,7 @@ class ServiceRepository @Inject constructor() {
get() = _statusMessage
fun setStatusMessage(text: String) {
if (connectionState.value != ConnectionState.CONNECTED) {
if (connectionState.value != ConnectionState.Connected) {
_statusMessage.value = text
}
}

View File

@@ -210,6 +210,7 @@
<string name="connection_status">Current connections:</string>
<string name="wifi_ip">Wifi IP:</string>
<string name="ethernet_ip">Ethernet IP:</string>
<string name="connecting">Connecting</string>
<string name="not_connected">Not connected</string>
<string name="connected_sleeping">Connected to radio, but it is sleeping</string>
<string name="app_too_old">Application update required</string>
@@ -953,4 +954,5 @@
<string name="unset">Unset - 0</string>
<string name="relayed_by">Relayed by: %1$s</string>
<string name="preserve_favorites">Preserve Favorites?</string>
<string name="usb_devices">USB Devices</string>
</resources>

View File

@@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.unknown_username
@@ -72,7 +73,7 @@ fun NodeItem(
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
isConnected: Boolean = false,
connectionState: ConnectionState,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
@@ -140,7 +141,7 @@ fun NodeItem(
isThisNode = isThisNode,
isFavorite = isFavorite,
isUnmessageable = unmessageable,
isConnected = isConnected,
connectionState = connectionState,
)
}
@@ -221,7 +222,14 @@ fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis())
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
0,
true,
currentTimeMillis = System.currentTimeMillis(),
connectionState = ConnectionState.Connected,
)
}
}
@@ -236,6 +244,7 @@ fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatN
distanceUnits = 1,
tempInFahrenheit = true,
currentTimeMillis = System.currentTimeMillis(),
connectionState = ConnectionState.Connected,
)
}
}

View File

@@ -23,8 +23,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.NoCell
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudSync
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,21 +42,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) {
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
connectionState: ConnectionState,
) {
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
TooltipBox(
@@ -63,10 +73,11 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
PlainTooltip {
Text(
stringResource(
if (isConnected) {
Res.string.connected
} else {
Res.string.disconnected
when (connectionState) {
ConnectionState.Connected -> Res.string.connected
ConnectionState.Connecting -> Res.string.connecting
ConnectionState.Disconnected -> Res.string.disconnected
ConnectionState.DeviceSleep -> Res.string.device_sleeping
},
),
)
@@ -74,21 +85,39 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
},
state = rememberTooltipState(),
) {
if (isConnected) {
@Suppress("MagicNumber")
Icon(
imageVector = Icons.TwoTone.CloudDone,
contentDescription = stringResource(Res.string.connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusGreen,
)
} else {
Icon(
imageVector = Icons.TwoTone.CloudOff,
contentDescription = stringResource(Res.string.not_connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusRed,
)
when (connectionState) {
ConnectionState.Connected -> {
Icon(
imageVector = Icons.TwoTone.CloudDone,
contentDescription = stringResource(Res.string.connected),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusGreen,
)
}
ConnectionState.Connecting -> {
Icon(
imageVector = Icons.TwoTone.CloudSync,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusOrange,
)
}
ConnectionState.Disconnected -> {
Icon(
imageVector = Icons.TwoTone.CloudOff,
contentDescription = stringResource(Res.string.connecting),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusRed,
)
}
ConnectionState.DeviceSleep -> {
Icon(
imageVector = Icons.TwoTone.Cloud,
contentDescription = stringResource(Res.string.device_sleeping),
modifier = Modifier.size(24.dp), // Smaller size for badge
tint = MaterialTheme.colorScheme.StatusYellow,
)
}
}
}
}
@@ -130,5 +159,10 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B
@Preview
@Composable
private fun StatusIconsPreview() {
NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
NodeStatusIcons(
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
connectionState = ConnectionState.Connected,
)
}

View File

@@ -140,7 +140,7 @@ fun NodeListScreen(
sharedContact = sharedContact,
modifier =
Modifier.animateFloatingActionButton(
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
@@ -217,7 +217,7 @@ fun NodeListScreen(
onClick = { navigateToNodeDetails(node.num) },
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
isConnected = connectionState.isConnected(),
connectionState = connectionState,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {

View File

@@ -169,7 +169,7 @@ constructor(
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, configState ->
_radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) }
_radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) }
}
.launchIn(viewModelScope)