Refactor: Improve connection state display in UI (#2353)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-07-05 22:32:03 +00:00
committed by GitHub
parent 5fdf383539
commit fa0679b3f2
9 changed files with 110 additions and 88 deletions

View File

@@ -24,6 +24,7 @@ import android.content.Context
import android.content.SharedPreferences
import android.hardware.usb.UsbManager
import android.os.RemoteException
import androidx.core.content.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -51,8 +52,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import org.json.JSONArray
import javax.inject.Inject
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
@@ -126,7 +127,7 @@ class BTScanModel @Inject constructor(
}
// Include saved IP connections
recent?.forEach { address ->
recent.forEach { address ->
addDevice(DeviceListEntry(context.getString(R.string.meshtastic), address, true))
}
@@ -303,9 +304,9 @@ class BTScanModel @Inject constructor(
}
private fun setRecentAddresses(addresses: List<String>) {
val editor = preferences.edit()
editor.putString("recent-ip-addresses", addresses.toString())
editor.apply()
preferences.edit {
putString("recent-ip-addresses", addresses.toString())
}
recentIpAddresses.value = addresses
}

View File

@@ -473,7 +473,7 @@ private fun TopBarActions(
) {
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false)
AnimatedVisibility(ourNode != null && currentDestination?.isTopLevel() == true) {
AnimatedVisibility(ourNode != null && currentDestination?.isTopLevel() == true && isConnected) {
ourNode?.let {
NodeChip(
node = it,

View File

@@ -42,17 +42,14 @@ import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
@@ -320,22 +317,6 @@ fun ConnectionsScreen(
contentDescription = stringResource(id = R.string.radio_configuration)
)
}
FilledIconButton(
colors = IconButtonDefaults.filledIconButtonColors().copy(
containerColor = MaterialTheme.colorScheme.error
),
enabled = true,
onClick = {
devices.values.find { it.isDisconnect }?.let {
scanModel.onSelected(it)
}
}
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = stringResource(id = R.string.disconnect),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -357,12 +338,11 @@ fun ConnectionsScreen(
}
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
// Determine based on the selected device - if disconnected, keep the last selected type
selectedDeviceType =
selectedDevice.let { DeviceType.fromAddress(it) } ?: selectedDeviceType
DeviceType.fromAddress(selectedDevice)?.let { type ->
selectedDeviceType = type
}
}
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth(),
@@ -436,7 +416,8 @@ fun ConnectionsScreen(
DeviceType.BLE -> {
BLEDevices(
devices.values.filter { it.isBLE },
connectionState,
devices.values.filter { it.isBLE || it.isDisconnect },
selectedDevice,
showBluetoothRationaleDialog = {
showBluetoothRationaleDialog = true
@@ -452,7 +433,8 @@ fun ConnectionsScreen(
DeviceType.TCP -> {
NetworkDevices(
devices.values.filter { it.isTCP },
connectionState,
devices.values.filter { it.isTCP || it.isDisconnect },
selectedDevice,
scanModel
)
@@ -460,7 +442,8 @@ fun ConnectionsScreen(
DeviceType.USB -> {
UsbDevices(
devices.values.filter { it.isUSB || it.isMock },
connectionState,
devices.values.filter { it.isUSB || it.isDisconnect || it.isMock },
selectedDevice,
scanModel
)
@@ -723,7 +706,6 @@ private enum class DeviceType {
val isUSB: Boolean = prefix == 's'
val isTCP: Boolean = prefix == 't'
val isMock: Boolean = prefix == 'm'
val isDisconnect: Boolean = prefix == 'n'
return when {
isBLE -> BLE
isUSB -> USB

View File

@@ -19,8 +19,6 @@ package com.geeksville.mesh.ui.connections.components
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -44,10 +42,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.android.getBluetoothPermissions
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.service.MeshService
@Suppress("LongMethod")
@Composable
fun BLEDevices(
connectionState: MeshService.ConnectionState,
btDevices: List<BTScanModel.DeviceListEntry>,
selectedDevice: String,
showBluetoothRationaleDialog: () -> Unit,
@@ -56,24 +56,21 @@ fun BLEDevices(
) {
val context = LocalContext.current
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
Row {
Text(
text = stringResource(R.string.bluetooth),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
Spacer(modifier = Modifier.weight(1f))
}
if (btDevices.isNotEmpty()) {
btDevices.forEach { device ->
DeviceListItem(
device,
device.fullAddress == selectedDevice
) {
scanModel.onSelected(device)
}
Text(
text = stringResource(R.string.bluetooth),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
btDevices.forEach { device ->
DeviceListItem(
connectionState,
device,
device.fullAddress == selectedDevice
) {
scanModel.onSelected(device)
}
} else if (isScanning) {
}
if (isScanning) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -89,7 +86,7 @@ fun BLEDevices(
modifier = Modifier.padding(vertical = 8.dp)
)
}
} else {
} else if (btDevices.filterNot { it.isDisconnect }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()

View File

@@ -23,12 +23,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -36,9 +39,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.service.MeshService
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DeviceListItem(
connectionState: MeshService.ConnectionState,
device: BTScanModel.DeviceListEntry,
selected: Boolean,
onSelect: () -> Unit,
@@ -49,7 +55,7 @@ fun DeviceListItem(
Icons.Default.Usb
} else if (device.isTCP) {
Icons.Default.Wifi
} else if (device.isDisconnect) {
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
Icons.Default.Cancel
} else {
Icons.Default.Add
@@ -61,12 +67,41 @@ fun DeviceListItem(
stringResource(R.string.serial)
} else if (device.isTCP) {
stringResource(R.string.network)
} else if (device.isDisconnect) {
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
stringResource(R.string.disconnect)
} else {
stringResource(R.string.add)
}
val colors = when {
selected && device.isDisconnect -> {
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.errorContainer,
headlineColor = MaterialTheme.colorScheme.onErrorContainer,
leadingIconColor = MaterialTheme.colorScheme.onErrorContainer,
supportingColor = MaterialTheme.colorScheme.onErrorContainer,
trailingIconColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
selected -> { // Standard selection for other device types
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
headlineColor = MaterialTheme.colorScheme.onPrimaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
trailingIconColor = when (connectionState) {
MeshService.ConnectionState.CONNECTED -> Color(color = 0xFF30C047)
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onPrimaryContainer // Fallback for other states (e.g. connecting)
},
)
}
else -> {
ListItemDefaults.colors()
}
}
ListItem(
modifier = Modifier
.fillMaxWidth()
@@ -77,7 +112,7 @@ fun DeviceListItem(
headlineContent = { Text(device.name) },
leadingContent = {
Icon(
icon,
icon, // icon is already CloudOff if device.isDisconnect
contentDescription
)
},
@@ -87,18 +122,23 @@ fun DeviceListItem(
}
},
trailingContent = {
if (selected) {
if (device.isDisconnect) {
Icon(
Icons.Outlined.CloudDone,
stringResource(R.string.connected),
tint = Color(color = 0xFF30C047)
imageVector = Icons.Default.CloudOff,
contentDescription = stringResource(R.string.disconnect),
)
} else if (connectionState == MeshService.ConnectionState.CONNECTED) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = stringResource(R.string.connected),
)
} else {
Icon(
Icons.Default.CloudQueue,
stringResource(R.string.not_connected)
imageVector = Icons.Default.CloudQueue,
contentDescription = stringResource(R.string.not_connected),
)
}
}
},
colors = colors
)
}

View File

@@ -45,30 +45,31 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.isIPAddress
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
fun NetworkDevices(
connectionState: MeshService.ConnectionState,
networkDevices: List<BTScanModel.DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
) {
val manualIpAddress = rememberTextFieldState("")
val manualIpPort = rememberTextFieldState(NetworkRepository.Companion.SERVICE_PORT.toString())
if (networkDevices.isNotEmpty()) {
Text(
text = stringResource(R.string.network),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
networkDevices.forEach { device ->
DeviceListItem(device, device.fullAddress == selectedDevice) {
scanModel.onSelected(device)
}
Text(
text = stringResource(R.string.network),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
networkDevices.forEach { device ->
DeviceListItem(connectionState, device, device.fullAddress == selectedDevice) {
scanModel.onSelected(device)
}
} else {
}
if (networkDevices.filterNot { it.isDisconnect }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -93,7 +94,7 @@ fun NetworkDevices(
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
horizontalArrangement = Arrangement.spacedBy(8.dp, CenterHorizontally)
) {
OutlinedTextField(
state = manualIpAddress,

View File

@@ -33,9 +33,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.service.MeshService
@Composable
fun UsbDevices(
connectionState: MeshService.ConnectionState,
usbDevices: List<BTScanModel.DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel
@@ -45,13 +47,12 @@ fun UsbDevices(
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
if (usbDevices.isNotEmpty()) {
usbDevices.forEach { device ->
DeviceListItem(device, device.fullAddress == selectedDevice) {
scanModel.onSelected(device)
}
usbDevices.forEach { device ->
DeviceListItem(connectionState, device, device.fullAddress == selectedDevice) {
scanModel.onSelected(device)
}
} else {
}
if (usbDevices.filterNot { it.isDisconnect || it.isMock }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()

View File

@@ -952,7 +952,10 @@ fun TracerouteActionButton(
@Suppress("LongMethod")
@Composable
fun NodeActionButton(
modifier: Modifier = Modifier,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.height(48.dp),
title: String,
enabled: Boolean,
icon: ImageVector? = null,
@@ -966,9 +969,6 @@ fun NodeActionButton(
},
enabled = enabled,
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.height(48.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,

View File

@@ -23,8 +23,8 @@ 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.CloudOff
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -150,6 +150,6 @@ fun StatusIconsPreview() {
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
isConnected = true,
isConnected = false,
)
}