refactor(connections)!: Use sealed class for device list entries (#2538)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-07-28 20:49:17 -05:00 committed by GitHub
parent aa6a048084
commit ceabafb545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 506 additions and 537 deletions

View file

@ -92,6 +92,8 @@ import com.geeksville.mesh.android.isGooglePlayAvailable
import com.geeksville.mesh.android.permissionMissing
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.ConfigRoute
@ -110,13 +112,11 @@ import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import kotlinx.coroutines.delay
fun String?.isIPAddress(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
this != null && Patterns.IP_ADDRESS.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this.toString())
}
fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
this != null && Patterns.IP_ADDRESS.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this.toString())
}
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber")
@ -128,7 +128,7 @@ fun ConnectionsScreen(
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onNavigateToRadioConfig: () -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
onConfigNavigate: (Route) -> Unit
onConfigNavigate: (Route) -> Unit,
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by uiViewModel.localConfig.collectAsState()
@ -136,7 +136,6 @@ fun ConnectionsScreen(
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED)
val devices by scanModel.devices.observeAsState(emptyMap())
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false)
val context = LocalContext.current
@ -144,9 +143,15 @@ fun ConnectionsScreen(
val info by uiViewModel.myNodeInfo.collectAsState()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState()
val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET &&
val regionUnset =
currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET &&
connectionState == MeshService.ConnectionState.CONNECTED
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle()
val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle()
/* Animate waiting for the configurations */
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
@ -201,39 +206,41 @@ fun ConnectionsScreen(
var showReportBugDialog by remember { mutableStateOf(false) }
// Remember the permission launchers
val requestLocationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
uiViewModel.setProvideLocation(true)
uiViewModel.meshService?.startProvideLocation()
} else {
debug("User denied location permission")
uiViewModel.showSnackbar(context.getString(R.string.why_background_required))
}
bluetoothViewModel.permissionsUpdated()
}
)
val requestLocationPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
uiViewModel.setProvideLocation(true)
uiViewModel.meshService?.startProvideLocation()
} else {
debug("User denied location permission")
uiViewModel.showSnackbar(context.getString(R.string.why_background_required))
}
bluetoothViewModel.permissionsUpdated()
},
)
val requestBluetoothPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
info("Bluetooth permissions granted")
// We need to call the scan function which is in the Fragment
// Since we can't directly call scanLeDevice() from Composable,
// we might need to rethink how scanning is triggered or
// pass the scan trigger as a lambda.
// For now, let's assume we trigger the scan outside the Composable
// after permissions are granted. We can add a callback to the ViewModel.
scanModel.startScan()
} else {
warn("Bluetooth permissions denied")
uiViewModel.showSnackbar(context.permissionMissing)
}
bluetoothViewModel.permissionsUpdated()
}
)
val requestBluetoothPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
info("Bluetooth permissions granted")
// We need to call the scan function which is in the Fragment
// Since we can't directly call scanLeDevice() from Composable,
// we might need to rethink how scanning is triggered or
// pass the scan trigger as a lambda.
// For now, let's assume we trigger the scan outside the Composable
// after permissions are granted. We can add a callback to the ViewModel.
scanModel.startScan()
} else {
warn("Bluetooth permissions denied")
uiViewModel.showSnackbar(context.permissionMissing)
}
bluetoothViewModel.permissionsUpdated()
},
)
// Observe scan results to show the dialog
if (scanResults.isNotEmpty()) {
@ -249,42 +256,32 @@ fun ConnectionsScreen(
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
}.let {
val firmwareString =
info?.firmwareString ?: context.getString(R.string.unknown)
val firmwareString = info?.firmwareString ?: context.getString(R.string.unknown)
scanModel.setErrorText(context.getString(it, firmwareString))
}
}
var showSharedContact by remember { mutableStateOf<Node?>(null) }
if (showSharedContact != null) {
SharedContactDialog(
contact = showSharedContact,
onDismiss = { showSharedContact = null }
)
SharedContactDialog(contact = showSharedContact, onDismiss = { showSharedContact = null })
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
fontSize = 14.sp,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
val isConnected by uiViewModel.isConnected.collectAsState(false)
val ourNode by uiViewModel.ourNodeInfo.collectAsState()
// Set the connected node long name for BTScanModel
scanModel.connectedNodeLongName = ourNode?.user?.longName
if (isConnected) {
ourNode?.let { node ->
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
NodeChip(
node = node,
@ -308,15 +305,12 @@ fun ConnectionsScreen(
Text(
modifier = Modifier.weight(1f, fill = true),
text = node.user.longName,
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.titleLarge,
)
IconButton(
enabled = true,
onClick = onNavigateToRadioConfig
) {
IconButton(enabled = true, onClick = onNavigateToRadioConfig) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration)
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
@ -330,124 +324,83 @@ fun ConnectionsScreen(
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
},
)
Spacer(modifier = Modifier.height(8.dp))
if (scanning) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
DeviceType.fromAddress(selectedDevice)?.let { type ->
selectedDeviceType = type
}
DeviceType.fromAddress(selectedDevice)?.let { type -> selectedDeviceType = type }
}
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth(),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
DeviceType.BLE.ordinal,
DeviceType.entries.size
),
onClick = {
selectedDeviceType = DeviceType.BLE
},
shape = SegmentedButtonDefaults.itemShape(DeviceType.BLE.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.BLE },
selected = (selectedDeviceType == DeviceType.BLE),
icon = {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = stringResource(id = R.string.bluetooth)
contentDescription = stringResource(id = R.string.bluetooth),
)
},
label = {
Text(text = stringResource(id = R.string.bluetooth))
}
label = { Text(text = stringResource(id = R.string.bluetooth)) },
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
DeviceType.TCP.ordinal,
DeviceType.entries.size
),
onClick = {
selectedDeviceType = DeviceType.TCP
},
shape = SegmentedButtonDefaults.itemShape(DeviceType.TCP.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.TCP },
selected = (selectedDeviceType == DeviceType.TCP),
icon = {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = stringResource(id = R.string.network)
contentDescription = stringResource(id = R.string.network),
)
},
label = {
Text(text = stringResource(id = R.string.network))
}
label = { Text(text = stringResource(id = R.string.network)) },
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
DeviceType.USB.ordinal,
DeviceType.entries.size
),
onClick = {
selectedDeviceType = DeviceType.USB
},
shape = SegmentedButtonDefaults.itemShape(DeviceType.USB.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.USB },
selected = (selectedDeviceType == DeviceType.USB),
icon = {
Icon(
imageVector = Icons.Default.Usb,
contentDescription = stringResource(id = R.string.serial)
)
Icon(imageVector = Icons.Default.Usb, contentDescription = stringResource(id = R.string.serial))
},
label = {
Text(text = stringResource(id = R.string.serial))
}
label = { Text(text = stringResource(id = R.string.serial)) },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.verticalScroll(scrollState)
) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(scrollState)) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState,
devices.values.filter { it.isBLE || it.isDisconnect },
selectedDevice,
showBluetoothRationaleDialog = {
showBluetoothRationaleDialog = true
},
requestBluetoothPermission = {
requestBluetoothPermissionLauncher.launch(
it
)
},
scanModel
connectionState = connectionState,
btDevices = bleDevices,
selectedDevice = selectedDevice,
showBluetoothRationaleDialog = { showBluetoothRationaleDialog = true },
requestBluetoothPermission = { requestBluetoothPermissionLauncher.launch(it) },
scanModel = scanModel,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState,
devices.values.filter { it.isTCP || it.isDisconnect },
selectedDevice,
scanModel
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.USB -> {
UsbDevices(
connectionState,
devices.values.filter { it.isUSB || it.isDisconnect || it.isMock },
selectedDevice,
scanModel
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
}
@ -472,27 +425,25 @@ fun ConnectionsScreen(
}
}
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = provideLocation,
onValueChange = { checked ->
uiViewModel.setProvideLocation(checked)
},
enabled = !isGpsDisabled
onValueChange = { checked -> uiViewModel.setProvideLocation(checked) },
enabled = !isGpsDisabled,
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = receivingLocationUpdates,
onCheckedChange = null,
enabled = !isGpsDisabled // Disable if GPS is disabled
enabled = !isGpsDisabled, // Disable if GPS is disabled
)
Text(
text = stringResource(R.string.provide_location_to_mesh),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(start = 16.dp),
)
}
}
@ -501,15 +452,21 @@ fun ConnectionsScreen(
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val showWarningNotPaired = !devices.any { it.value.bonded }
val hasShownNotPairedWarning by uiViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val showWarningNotPaired =
!isConnected &&
!hasShownNotPairedWarning &&
bleDevices.none { it is DeviceListEntry.Ble && it.bonded }
if (showWarningNotPaired) {
Text(
text = stringResource(R.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp)
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { uiViewModel.suppressNoPairedWarning() }
}
// Analytics Okay Checkbox
@ -518,12 +475,10 @@ fun ConnectionsScreen(
val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
if (isGooglePlayAvailable) {
var loading by remember { mutableStateOf(false) }
LaunchedEffect(isAnalyticsAllowed) {
loading = false
}
LaunchedEffect(isAnalyticsAllowed) { loading = false }
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = isAnalyticsAllowed,
onValueChange = {
@ -532,32 +487,24 @@ fun ConnectionsScreen(
loading = true
},
role = Role.Checkbox,
enabled = isGooglePlayAvailable && !loading
enabled = isGooglePlayAvailable && !loading,
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
enabled = isGooglePlayAvailable,
checked = isAnalyticsAllowed,
onCheckedChange = null
)
Checkbox(enabled = isGooglePlayAvailable, checked = isAnalyticsAllowed, onCheckedChange = null)
Text(
text = stringResource(R.string.analytics_okay),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(start = 16.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
// Report Bug Button
Button(
onClick = {
showReportBugDialog = true
}, // Set state to show Report Bug dialog
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
enabled = isAnalyticsAllowed
onClick = { showReportBugDialog = true }, // Set state to show Report Bug dialog
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
enabled = isAnalyticsAllowed,
) {
Text(stringResource(R.string.report_bug))
}
@ -565,44 +512,48 @@ fun ConnectionsScreen(
}
}
// Compose Device Scan Dialog
// Compose Device Scan Dialog
if (showScanDialog) {
Dialog(onDismissRequest = {
showScanDialog = false
scanModel.clearScanResults()
}) {
Dialog(
onDismissRequest = {
showScanDialog = false
scanModel.clearScanResults()
},
) {
Surface(shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Select a Bluetooth device",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 16.dp),
)
Column(modifier = Modifier.selectableGroup()) {
scanResults.values.forEach { device ->
Row(
modifier = Modifier
.fillMaxWidth()
modifier =
Modifier.fillMaxWidth()
.selectable(
selected = false, // No pre-selection in this dialog
onClick = {
scanModel.onSelected(device)
scanModel.clearScanResults()
showScanDialog = false
}
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = device.name)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = {
scanModel.clearScanResults()
showScanDialog = false
}) {
TextButton(
onClick = {
scanModel.clearScanResults()
showScanDialog = false
},
) {
Text(stringResource(R.string.cancel))
}
}
@ -610,31 +561,31 @@ fun ConnectionsScreen(
}
}
// Compose Location Permission Rationale Dialog
// Compose Location Permission Rationale Dialog
if (showLocationRationaleDialog) {
AlertDialog(
onDismissRequest = { showLocationRationaleDialog = false },
title = { Text(stringResource(R.string.background_required)) },
text = { Text(stringResource(R.string.why_background_required)) },
confirmButton = {
Button(onClick = {
showLocationRationaleDialog = false
if (!context.hasLocationPermission()) {
requestLocationPermissionLauncher.launch(context.getLocationPermissions())
}
}) {
Button(
onClick = {
showLocationRationaleDialog = false
if (!context.hasLocationPermission()) {
requestLocationPermissionLauncher.launch(context.getLocationPermissions())
}
},
) {
Text(stringResource(R.string.accept))
}
},
dismissButton = {
Button(onClick = { showLocationRationaleDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
Button(onClick = { showLocationRationaleDialog = false }) { Text(stringResource(R.string.cancel)) }
},
)
}
// Compose Bluetooth Permission Rationale Dialog
// Compose Bluetooth Permission Rationale Dialog
if (showBluetoothRationaleDialog) {
val bluetoothPermissions = context.getBluetoothPermissions()
AlertDialog(
@ -642,49 +593,53 @@ fun ConnectionsScreen(
title = { Text(stringResource(R.string.required_permissions)) },
text = { Text(stringResource(R.string.permission_missing_31)) },
confirmButton = {
Button(onClick = {
showBluetoothRationaleDialog = false
if (bluetoothPermissions.isNotEmpty()) {
requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
} else {
// If somehow no permissions are required, just scan
scanModel.startScan()
}
}) {
Button(
onClick = {
showBluetoothRationaleDialog = false
if (bluetoothPermissions.isNotEmpty()) {
requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
} else {
// If somehow no permissions are required, just scan
scanModel.startScan()
}
},
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = {
Button(onClick = { showBluetoothRationaleDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
Button(onClick = { showBluetoothRationaleDialog = false }) { Text(stringResource(R.string.cancel)) }
},
)
}
// Compose Report Bug Dialog
// Compose Report Bug Dialog
if (showReportBugDialog) {
AlertDialog(
onDismissRequest = { showReportBugDialog = false },
title = { Text(stringResource(R.string.report_a_bug)) },
text = { Text(stringResource(R.string.report_bug_text)) },
confirmButton = {
Button(onClick = {
showReportBugDialog = false
reportError("Clicked Report A Bug")
uiViewModel.showSnackbar("Bug report sent!")
}) {
Button(
onClick = {
showReportBugDialog = false
reportError("Clicked Report A Bug")
uiViewModel.showSnackbar("Bug report sent!")
},
) {
Text(stringResource(R.string.report))
}
},
dismissButton = {
Button(onClick = {
showReportBugDialog = false
debug("Decided not to report a bug")
}) {
Button(
onClick = {
showReportBugDialog = false
debug("Decided not to report a bug")
},
) {
Text(stringResource(R.string.cancel))
}
}
},
)
}
}
@ -699,22 +654,21 @@ private tailrec fun Context.findActivity(): Activity = when (this) {
private enum class DeviceType {
BLE,
TCP,
USB;
USB,
;
companion object {
fun fromAddress(address: String): DeviceType? {
val prefix = address[0]
val isBLE: Boolean = prefix == 'x'
val isUSB: Boolean = prefix == 's'
val isTCP: Boolean = prefix == 't'
val isMock: Boolean = prefix == 'm'
return when {
isBLE -> BLE
isUSB -> USB
isTCP -> TCP
isMock -> USB // Treat mock as USB for UI purposes
else -> null
}
fun fromAddress(address: String): DeviceType? = when (address.firstOrNull()) {
'x' -> BLE
's' -> USB
't' -> TCP
'm' -> USB // Treat mock as USB for UI purposes
'n' ->
when (address) {
NO_DEVICE_SELECTED -> null
else -> null
}
else -> null
}
}
}

View file

@ -42,24 +42,25 @@ 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.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
@Suppress("LongMethod")
@Composable
fun BLEDevices(
connectionState: MeshService.ConnectionState,
btDevices: List<BTScanModel.DeviceListEntry>,
btDevices: List<DeviceListEntry>,
selectedDevice: String,
showBluetoothRationaleDialog: () -> Unit,
requestBluetoothPermission: (Array<String>) -> Unit,
scanModel: BTScanModel
scanModel: BTScanModel,
) {
val context = LocalContext.current
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
Text(
text = stringResource(R.string.bluetooth),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
btDevices.forEach { device ->
DeviceListItem(
@ -67,41 +68,29 @@ fun BLEDevices(
device = device,
selected = device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
modifier = Modifier
modifier = Modifier,
)
}
if (isScanning) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(96.dp)
)
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(96.dp))
Text(
text = stringResource(R.string.scanning),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
}
} else if (btDevices.filterNot { it.isDisconnect }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = CenterHorizontally
) {
} else if (btDevices.filterNot { it is DeviceListEntry.Disconnect }.isEmpty()) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) {
Icon(
imageVector = Icons.Default.BluetoothDisabled,
contentDescription = stringResource(R.string.no_ble_devices),
modifier = Modifier.size(96.dp)
modifier = Modifier.size(96.dp),
)
Text(
text = stringResource(R.string.no_ble_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
@ -114,11 +103,9 @@ fun BLEDevices(
// If no permissions needed, trigger the scan directly (or via ViewModel)
scanModel.startScan()
} else {
if (bluetoothPermissions.any { permission ->
ActivityCompat.shouldShowRequestPermissionRationale(
context as Activity,
permission
)
if (
bluetoothPermissions.any { permission ->
ActivityCompat.shouldShowRequestPermissionRationale(context as Activity, permission)
}
) {
showBluetoothRationaleDialog()
@ -126,12 +113,9 @@ fun BLEDevices(
requestBluetoothPermission(bluetoothPermissions)
}
}
}
},
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = stringResource(R.string.scan)
)
Icon(imageVector = Icons.Default.Bluetooth, contentDescription = stringResource(R.string.scan))
Text(stringResource(R.string.scan))
}
}

View file

@ -37,7 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
@ -46,40 +46,32 @@ import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
@Composable
fun DeviceListItem(
connectionState: MeshService.ConnectionState,
device: BTScanModel.DeviceListEntry,
device: DeviceListEntry,
selected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
val icon =
if (device.isBLE) {
Icons.Default.Bluetooth
} else if (device.isUSB) {
Icons.Default.Usb
} else if (device.isTCP) {
Icons.Default.Wifi
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
Icons.Default.Cancel
} else {
Icons.Default.Add
when (device) {
is DeviceListEntry.Ble -> Icons.Default.Bluetooth
is DeviceListEntry.Usb -> Icons.Default.Usb
is DeviceListEntry.Tcp -> Icons.Default.Wifi
is DeviceListEntry.Disconnect -> Icons.Default.Cancel
is DeviceListEntry.Mock -> Icons.Default.Add
}
val contentDescription =
if (device.isBLE) {
stringResource(R.string.bluetooth)
} else if (device.isUSB) {
stringResource(R.string.serial)
} else if (device.isTCP) {
stringResource(R.string.network)
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
stringResource(R.string.disconnect)
} else {
stringResource(R.string.add)
when (device) {
is DeviceListEntry.Ble -> stringResource(R.string.bluetooth)
is DeviceListEntry.Usb -> stringResource(R.string.serial)
is DeviceListEntry.Tcp -> stringResource(R.string.network)
is DeviceListEntry.Disconnect -> stringResource(R.string.disconnect)
is DeviceListEntry.Mock -> stringResource(R.string.add)
}
val colors =
when {
selected && device.isDisconnect -> {
selected && device is DeviceListEntry.Disconnect -> {
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.errorContainer,
headlineColor = MaterialTheme.colorScheme.onErrorContainer,
@ -126,12 +118,12 @@ fun DeviceListItem(
)
},
supportingContent = {
if (device.isTCP) {
if (device is DeviceListEntry.Tcp) {
Text(device.address)
}
},
trailingContent = {
if (device.isDisconnect) {
if (device is DeviceListEntry.Disconnect) {
Icon(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))

View file

@ -17,6 +17,8 @@
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -28,6 +30,8 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WifiFind
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -35,6 +39,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
@ -44,52 +52,73 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.isIPAddress
import androidx.compose.foundation.combinedClickable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
fun NetworkDevices(
connectionState: MeshService.ConnectionState,
networkDevices: List<BTScanModel.DeviceListEntry>,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
) {
val manualIpAddress = rememberTextFieldState("")
val manualIpPort = rememberTextFieldState(NetworkRepository.Companion.SERVICE_PORT.toString())
var showDeleteDialog by remember { mutableStateOf(false) }
var deviceToDelete by remember { mutableStateOf<BTScanModel.DeviceListEntry?>(null) }
var deviceToDelete by remember { mutableStateOf<DeviceListEntry?>(null) }
Text(
text = stringResource(R.string.network),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
networkDevices.forEach { device ->
val isRecent = device.isTCP && device.fullAddress.startsWith("t")
val modifier = if (isRecent) {
Modifier.combinedClickable(
onClick = { scanModel.onSelected(device) },
onLongClick = {
deviceToDelete = device
showDeleteDialog = true
}
)
} else {
Modifier
}
DeviceListItem(
connectionState, device, device.fullAddress == selectedDevice, onSelect = { scanModel.onSelected(device) },
modifier = modifier
DeviceListItem(
connectionState = connectionState,
device = scanModel.disconnectDevice,
selected = scanModel.disconnectDevice.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(scanModel.disconnectDevice) },
)
if (discoveredNetworkDevices.isNotEmpty()) {
Text(
text = stringResource(R.string.discovered_network_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
discoveredNetworkDevices.forEach { device ->
DeviceListItem(
connectionState,
device,
device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
)
}
}
if (recentNetworkDevices.isNotEmpty()) {
Text(
text = stringResource(R.string.recent_network_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
recentNetworkDevices.forEach { device ->
DeviceListItem(
connectionState,
device,
device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
modifier =
Modifier.combinedClickable(
onClick = { scanModel.onSelected(device) },
onLongClick = {
deviceToDelete = device
showDeleteDialog = true
},
),
)
}
}
if (showDeleteDialog && deviceToDelete != null) {
AlertDialog(
@ -97,94 +126,78 @@ fun NetworkDevices(
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete_node)) },
confirmButton = {
Button(onClick = {
scanModel.removeRecentAddress(deviceToDelete!!.fullAddress)
showDeleteDialog = false
}) {
Button(
onClick = {
scanModel.removeRecentAddress(deviceToDelete!!.fullAddress)
showDeleteDialog = false
},
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
Button(onClick = { showDeleteDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
Button(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
},
)
}
if (networkDevices.filterNot { it.isDisconnect }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = CenterHorizontally
) {
if (discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty()) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) {
Icon(
imageVector = Icons.Default.WifiFind,
contentDescription = stringResource(R.string.no_network_devices),
modifier = Modifier.size(96.dp)
modifier = Modifier.size(96.dp),
)
Text(
text = stringResource(R.string.no_network_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, CenterHorizontally)
horizontalArrangement = Arrangement.spacedBy(8.dp, CenterHorizontally),
) {
OutlinedTextField(
state = manualIpAddress,
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(R.string.ip_address)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Companion.Decimal,
imeAction = ImeAction.Next
),
modifier = Modifier.weight(.7f, fill = false) // Fill 70% of the space
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Companion.Decimal, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f, fill = false), // Fill 70% of the space
)
OutlinedTextField(
state = manualIpPort,
placeholder = { Text(NetworkRepository.SERVICE_PORT.toString()) },
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(R.string.ip_port)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Companion.Decimal,
imeAction = ImeAction.Done
),
modifier = Modifier.weight(.3f, fill = false) // Fill remaining space
keyboardOptions =
KeyboardOptions(keyboardType = KeyboardType.Companion.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(.3f, fill = false), // Fill remaining space
)
IconButton(
onClick = {
if (manualIpAddress.text.toString().isIPAddress()) {
val fullAddress =
"t" + if (
manualIpPort.text.isNotEmpty() &&
manualIpPort.text.toString().toInt() != NetworkRepository.SERVICE_PORT
) {
"${manualIpAddress.text}:${manualIpPort.text}"
} else {
"${manualIpAddress.text}"
}
scanModel.onSelected(
BTScanModel.DeviceListEntry(
"${manualIpAddress.text}",
fullAddress,
true
)
)
"t" +
if (
manualIpPort.text.isNotEmpty() &&
manualIpPort.text.toString().toInt() != NetworkRepository.SERVICE_PORT
) {
"${manualIpAddress.text}:${manualIpPort.text}"
} else {
manualIpAddress.text.toString()
}
scanModel.onSelected(DeviceListEntry.Tcp(manualIpAddress.text.toString(), fullAddress))
}
}
},
) {
Icon(
imageVector = Icons.Default.WifiFind,
contentDescription = stringResource(R.string.add),
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp),
)
}
}

View file

@ -33,19 +33,20 @@ 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.model.DeviceListEntry
import com.geeksville.mesh.service.MeshService
@Composable
fun UsbDevices(
connectionState: MeshService.ConnectionState,
usbDevices: List<BTScanModel.DeviceListEntry>,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel
scanModel: BTScanModel,
) {
Text(
text = stringResource(R.string.serial),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
usbDevices.forEach { device ->
DeviceListItem(
@ -53,25 +54,20 @@ fun UsbDevices(
device = device,
selected = device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
modifier = Modifier
modifier = Modifier,
)
}
if (usbDevices.filterNot { it.isDisconnect || it.isMock }.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = CenterHorizontally
) {
if (usbDevices.filterNot { it is DeviceListEntry.Disconnect || it is DeviceListEntry.Mock }.isEmpty()) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalAlignment = CenterHorizontally) {
Icon(
imageVector = Icons.Default.UsbOff,
contentDescription = stringResource(R.string.no_usb_devices),
modifier = Modifier.size(96.dp)
modifier = Modifier.size(96.dp),
)
Text(
text = stringResource(R.string.no_usb_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
}
}