Clean up Connections UI, fix some friction with Bluetooth (#2807)

This commit is contained in:
Phil Oliver
2025-08-22 14:33:11 -04:00
committed by GitHub
parent 268be3e4f9
commit 2d5e73c410
10 changed files with 981 additions and 678 deletions

View File

@@ -92,8 +92,6 @@ sealed class DeviceListEntry(open val name: String, open val fullAddress: String
data class Tcp(override val name: String, override val fullAddress: String) :
DeviceListEntry(name, fullAddress, true)
data class Disconnect(override val name: String) : DeviceListEntry(name, NO_DEVICE_SELECTED, true)
data class Mock(override val name: String) : DeviceListEntry(name, "m", true)
}
@@ -168,18 +166,10 @@ constructor(
.map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val disconnectDevice = DeviceListEntry.Disconnect(context.getString(R.string.none))
val mockDevice = DeviceListEntry.Mock("Demo Mode")
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
bleDevicesFlow
.map { devices -> listOf(disconnectDevice) + devices }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS),
listOf(disconnectDevice),
)
bleDevicesFlow.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), emptyList())
/** UI StateFlow for discovered TCP devices. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
@@ -199,12 +189,12 @@ constructor(
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
listOf(disconnectDevice) + usb + if (showMock) listOf(mockDevice) else emptyList()
usb + if (showMock) listOf(mockDevice) else emptyList()
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS),
listOf(disconnectDevice),
if (showMockInterface.value) listOf(mockDevice) else emptyList(),
)
init {
@@ -379,15 +369,17 @@ constructor(
true
}
is DeviceListEntry.Disconnect,
is DeviceListEntry.Mock,
-> {
is DeviceListEntry.Mock -> {
changeDeviceAddress(it.fullAddress)
true
}
}
}
fun disconnect() {
changeDeviceAddress(NO_DEVICE_SELECTED)
}
private val _spinner = MutableStateFlow(false)
val spinner: StateFlow<Boolean>
get() = _spinner.asStateFlow()

View File

@@ -27,6 +27,7 @@ import android.util.Patterns
import androidx.compose.animation.AnimatedVisibility
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
@@ -34,7 +35,6 @@ 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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
@@ -42,15 +42,16 @@ 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.Settings
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -68,6 +69,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -97,12 +99,11 @@ import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedCard
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.geeksville.mesh.ui.node.NodeActionButton
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
import com.geeksville.mesh.ui.sharing.SharedContactDialog
@@ -181,11 +182,6 @@ fun ConnectionsScreen(
uiViewModel.showSnackBar(context.getString(R.string.location_disabled))
}
}
LaunchedEffect(bluetoothEnabled) {
if (!bluetoothEnabled) {
uiViewModel.showSnackBar(context.getString(R.string.bluetooth_disabled))
}
}
// when scanning is true - wait 10000ms and then stop scanning
LaunchedEffect(scanning) {
if (scanning) {
@@ -245,352 +241,329 @@ fun ConnectionsScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
fontSize = 14.sp,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth(),
)
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).height(IntrinsicSize.Max).padding(16.dp),
) {
val isConnected by uiViewModel.isConnectedStateFlow.collectAsState(false)
val ourNode by uiViewModel.ourNodeInfo.collectAsState()
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(visible = isConnected, modifier = Modifier.padding(bottom = 16.dp)) {
Column {
ourNode?.let { node ->
Text(
stringResource(R.string.connected_device),
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleLarge,
)
val isConnected by uiViewModel.isConnectedStateFlow.collectAsState(false)
val ourNode by uiViewModel.ourNodeInfo.collectAsState()
if (isConnected) {
ourNode?.let { node ->
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NodeChip(
node = node,
isThisNode = true,
isConnected = true,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> {
onNavigateToNodeDetails(node.num)
}
Spacer(modifier = Modifier.height(8.dp))
is NodeMenuAction.Share -> {
showSharedContact = node
}
CurrentlyConnectedCard(
node = node,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onSetShowSharedContact = { showSharedContact = it },
onNavigateToRadioConfig = onNavigateToRadioConfig,
onClickDisconnect = { scanModel.disconnect() },
)
}
else -> {}
}
Spacer(modifier = Modifier.height(16.dp))
Card {
SwitchPreference(
title = stringResource(R.string.provide_location_to_mesh),
checked = provideLocation,
enabled = !isGpsDisabled,
onCheckedChange = { checked -> uiViewModel.setProvideLocation(checked) },
containerColor = Color.Transparent,
)
}
}
}
val setRegionText = stringResource(id = R.string.set_your_region)
val actionText = stringResource(id = R.string.action_go)
LaunchedEffect(isConnected && regionUnset && selectedDevice != "m") {
if (isConnected && regionUnset && selectedDevice != "m") {
uiViewModel.showSnackBar(
text = setRegionText,
actionLabel = actionText,
onActionPerformed = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(
modifier = Modifier.weight(1f, fill = true),
text = node.user.longName,
style = MaterialTheme.typography.titleLarge,
)
IconButton(enabled = true, onClick = onNavigateToRadioConfig) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
if (regionUnset && selectedDevice != "m") {
NodeActionButton(
title = stringResource(id = R.string.set_your_region),
icon = ConfigRoute.LORA.icon,
enabled = true,
onClick = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
DeviceType.fromAddress(selectedDevice)?.let { type -> selectedDeviceType = type }
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(DeviceType.BLE.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.BLE },
selected = (selectedDeviceType == DeviceType.BLE),
icon = {
Icon(
imageVector = Icons.Rounded.Bluetooth,
contentDescription = stringResource(id = R.string.bluetooth),
// modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
},
label = {
Text(
text = stringResource(id = R.string.bluetooth),
maxLines = 1,
softWrap = true,
// textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
Spacer(modifier = Modifier.height(8.dp))
if (scanning) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
LaunchedEffect(selectedDevice) {
DeviceType.fromAddress(selectedDevice)?.let { type -> selectedDeviceType = type }
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
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),
modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
},
label = {
Text(
text = stringResource(id = R.string.bluetooth),
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
softWrap = true,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
SegmentedButton(
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),
modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
},
label = {
Text(
text = stringResource(id = R.string.network),
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
softWrap = true,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
SegmentedButton(
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),
modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
},
label = {
Text(
text = stringResource(id = R.string.serial),
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
softWrap = true,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
}
Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(scrollState)) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState = connectionState,
btDevices = bleDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
}
Spacer(modifier = Modifier.weight(1f))
LaunchedEffect(ourNode) {
if (ourNode != null) {
uiViewModel.refreshProvideLocation()
}
}
AnimatedVisibility(isConnected) {
Row(
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = provideLocation,
onValueChange = { checked -> uiViewModel.setProvideLocation(checked) },
enabled = !isGpsDisabled,
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(DeviceType.TCP.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.TCP },
selected = (selectedDeviceType == DeviceType.TCP),
icon = {
Icon(
imageVector = Icons.Rounded.Wifi,
contentDescription = stringResource(id = R.string.network),
modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
// Checked state driven by receivingLocationUpdates for visual feedback
// but toggle action drives provideLocation
checked = receivingLocationUpdates,
onCheckedChange = null, // Toggleable handles the change
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),
)
}
}
// Provide Location Checkbox
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
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),
},
label = {
Text(
text = stringResource(id = R.string.network),
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
softWrap = true,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { uiViewModel.suppressNoPairedWarning() }
}
// Analytics Okay Checkbox
val isGooglePlayAvailable = context.isGooglePlayAvailable
val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
if (isGooglePlayAvailable) {
var loading by remember { mutableStateOf(false) }
LaunchedEffect(isAnalyticsAllowed) { loading = false }
Row(
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = isAnalyticsAllowed,
onValueChange = {
debug("User changed analytics to $it")
app.isAnalyticsAllowed = it
loading = true
},
role = Role.Checkbox,
enabled = isGooglePlayAvailable && !loading,
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(DeviceType.USB.ordinal, DeviceType.entries.size),
onClick = { selectedDeviceType = DeviceType.USB },
selected = (selectedDeviceType == DeviceType.USB),
icon = {
Icon(
imageVector = Icons.Rounded.Usb,
contentDescription = stringResource(id = R.string.serial),
modifier = Modifier.padding(end = 8.dp), // Add padding to separate icon from text
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(enabled = isGooglePlayAvailable, checked = isAnalyticsAllowed, onCheckedChange = null)
Text(
text = stringResource(R.string.analytics_okay),
style = MaterialTheme.typography.bodyLarge,
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,
) {
Text(stringResource(R.string.report_bug))
}
},
label = {
Text(
text = stringResource(id = R.string.serial),
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
softWrap = true,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
// Compose Device Scan Dialog
if (showScanDialog) {
Dialog(
onDismissRequest = {
showScanDialog = false
scanModel.clearScanResults()
},
) {
Surface(shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(16.dp)) {
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.fillMaxSize()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
BLEDevices(
connectionState = connectionState,
btDevices = bleDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
bluetoothEnabled = bluetoothEnabled,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
}
}
LaunchedEffect(ourNode) {
if (ourNode != null) {
uiViewModel.refreshProvideLocation()
}
}
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val hasShownNotPairedWarning by uiViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val showWarningNotPaired =
!isConnected &&
!hasShownNotPairedWarning &&
bleDevices.none { it is DeviceListEntry.Ble && it.bonded }
if (showWarningNotPaired) {
Text(
text = "Select a Bluetooth device",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp),
text = stringResource(R.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Column(modifier = Modifier.selectableGroup()) {
scanResults.values.forEach { device ->
Row(
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,
) {
Text(text = device.name)
}
}
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { uiViewModel.suppressNoPairedWarning() }
}
// Analytics Okay Checkbox
val isGooglePlayAvailable = context.isGooglePlayAvailable
val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
if (isGooglePlayAvailable) {
var loading by remember { mutableStateOf(false) }
LaunchedEffect(isAnalyticsAllowed) { loading = false }
Row(
modifier =
Modifier.fillMaxWidth()
.toggleable(
value = isAnalyticsAllowed,
onValueChange = {
debug("User changed analytics to $it")
app.isAnalyticsAllowed = it
loading = true
},
role = Role.Checkbox,
enabled = isGooglePlayAvailable && !loading,
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
enabled = isGooglePlayAvailable,
checked = isAnalyticsAllowed,
onCheckedChange = null,
)
Text(
text = stringResource(R.string.analytics_okay),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
// Report Bug Button
Button(
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))
}
}
}
}
// Compose Device Scan Dialog
if (showScanDialog) {
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),
)
Column(modifier = Modifier.selectableGroup()) {
scanResults.values.forEach { device ->
Row(
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,
) {
Text(text = device.name)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scanModel.clearScanResults()
showScanDialog = false
},
) {
Text(stringResource(R.string.cancel))
}
}
}
}
}
// 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 = {
scanModel.clearScanResults()
showScanDialog = false
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")
},
) {
Text(stringResource(R.string.cancel))
}
}
}
},
)
}
}
// 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!")
},
) {
Text(stringResource(R.string.report))
}
},
dismissButton = {
Button(
onClick = {
showReportBugDialog = false
debug("Decided not to report a bug")
},
) {
Text(stringResource(R.string.cancel))
}
},
Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Text(
text = scanStatusText.orEmpty(),
fontSize = 10.sp,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@@ -18,25 +18,30 @@
package com.geeksville.mesh.ui.connections.components
import android.Manifest
import android.content.Intent
import android.os.Build
import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothDisabled
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -46,6 +51,7 @@ import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.ConnectionState
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
/**
@@ -58,13 +64,14 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun BLEDevices(
connectionState: ConnectionState,
btDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
LocalContext.current // Used implicitly by stringResource
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
@@ -83,7 +90,7 @@ fun BLEDevices(
rememberMultiplePermissionsState(
permissions = bluetoothPermissionsList,
onPermissionsResult = {
if (it.values.all { granted -> granted }) {
if (it.values.all { granted -> granted } && bluetoothEnabled) {
scanModel.startScan()
scanModel.refreshPermissions()
} else {
@@ -92,87 +99,116 @@ fun BLEDevices(
},
)
Text(
text = stringResource(R.string.bluetooth),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp),
)
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
// Eventually auto scan once bluetooth is available
// checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled)
}
if (permissionsState.allPermissionsGranted) {
btDevices.forEach { device ->
DeviceListItem(
connectionState = connectionState,
device = device,
selected = device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
modifier = Modifier,
)
}
if (isScanning) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(modifier = Modifier.size(96.dp))
Text(
text = stringResource(R.string.scanning),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (permissionsState.allPermissionsGranted) {
when {
!bluetoothEnabled -> {
val context = LocalContext.current
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text = stringResource(R.string.bluetooth_disabled),
actionButton = {
val intent = Intent(ACTION_BLUETOOTH_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null) {
Button(onClick = { settingsLauncher.launch(intent) }) {
Text(text = stringResource(R.string.open_settings))
}
}
},
)
}
else -> {
val scanButton: @Composable () -> Unit = {
Button(
enabled = !isScanning,
onClick = { checkPermissionsAndScan(permissionsState, scanModel, true) },
) {
Box {
// Still measure for the icon and text when scanning, so the button's size doesn't jump
// around.
Row(modifier = Modifier.alpha(if (isScanning) 0f else 1f)) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(R.string.scan),
)
Text(stringResource(R.string.scan))
}
if (isScanning) {
CircularProgressIndicator(modifier = Modifier.size(24.dp).align(Alignment.Center))
}
}
}
}
if (btDevices.isEmpty()) {
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text =
if (isScanning) {
stringResource(R.string.scanning_bluetooth)
} else {
stringResource(R.string.no_ble_devices)
},
actionButton = scanButton,
)
} else {
TitledCard(title = stringResource(R.string.bluetooth_paired_devices)) {
btDevices.forEach { device ->
val connected =
connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice
DeviceListItem(
connected = connected,
device = device,
onSelect = { scanModel.onSelected(device) },
modifier = Modifier,
)
}
}
scanButton()
}
}
}
} else if (btDevices.filterNot { it is DeviceListEntry.Disconnect }.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.BluetoothDisabled,
contentDescription = stringResource(R.string.no_ble_devices),
modifier = Modifier.size(96.dp),
)
Text(
text = stringResource(R.string.no_ble_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
} else {
// Show a message and a button to grant permissions if not all granted
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val textToShow =
} else {
// Show a message and a button to grant permissions if not all granted
EmptyStateContent(
text =
if (permissionsState.shouldShowRationale) {
stringResource(R.string.permission_missing)
} else {
stringResource(R.string.permission_missing_31)
}
Text(text = textToShow, style = MaterialTheme.typography.bodyMedium)
},
actionButton = {
Button(onClick = { checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) }) {
Text(text = stringResource(R.string.grant_permissions))
}
},
)
}
}
}
Button(
enabled = !isScanning, // Keep disabled during scan
modifier = Modifier.fillMaxWidth(),
onClick = {
if (permissionsState.allPermissionsGranted) {
scanModel.startScan()
} else {
permissionsState.launchMultiplePermissionRequest()
}
},
) {
Icon(imageVector = Icons.Default.Bluetooth, contentDescription = stringResource(R.string.scan))
Text(
if (permissionsState.allPermissionsGranted) {
stringResource(R.string.scan)
} else {
stringResource(R.string.grant_permissions_and_scan)
},
)
@OptIn(ExperimentalPermissionsApi::class)
private fun checkPermissionsAndScan(
permissionsState: MultiplePermissionsState,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
if (permissionsState.allPermissionsGranted && bluetoothEnabled) {
scanModel.startScan()
} else {
permissionsState.launchMultiplePermissionRequest()
}
}

View File

@@ -0,0 +1,149 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.common.components.MaterialBatteryInfo
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@Composable
fun CurrentlyConnectedCard(
node: Node,
modifier: Modifier = Modifier,
onNavigateToNodeDetails: (Int) -> Unit,
onSetShowSharedContact: (Node) -> Unit,
onNavigateToRadioConfig: () -> Unit,
onClickDisconnect: () -> Unit,
) {
Card(modifier = modifier) {
Column {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
NodeChip(
node = node,
isThisNode = true,
isConnected = true,
onAction = { action ->
when (action) {
is NodeMenuAction.MoreDetails -> onNavigateToNodeDetails(node.num)
is NodeMenuAction.Share -> onSetShowSharedContact(node)
else -> {}
}
},
)
MaterialBatteryInfo(level = node.batteryLevel)
}
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(text = node.user.longName, style = MaterialTheme.typography.titleMedium)
node.metadata?.firmwareVersion?.let { firmwareVersion ->
Text(
text = stringResource(R.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(enabled = true, onClick = onNavigateToRadioConfig) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
contentColor = Color.White,
),
onClick = onClickDisconnect,
) {
Text(stringResource(R.string.disconnect))
}
}
}
}
@Suppress("MagicNumber")
@PreviewLightDark
@Composable
private fun CurrentlyConnectedCardPreview() {
AppTheme {
CurrentlyConnectedCard(
node =
Node(
num = 13444,
user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(25f)
.setRelativeHumidity(60f)
.build(),
),
onNavigateToNodeDetails = {},
onSetShowSharedContact = {},
onNavigateToRadioConfig = {},
onClickDisconnect = {},
)
}
}

View File

@@ -17,47 +17,40 @@
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.selection.selectable
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.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.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.res.stringResource
import com.geeksville.mesh.R
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DeviceListItem(
connectionState: ConnectionState,
device: DeviceListEntry,
selected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
) {
fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () -> Unit, modifier: Modifier = Modifier) {
val icon =
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
is DeviceListEntry.Ble ->
if (connected) {
Icons.Rounded.BluetoothConnected
} else {
Icons.Rounded.Bluetooth
}
is DeviceListEntry.Usb -> Icons.Rounded.Usb
is DeviceListEntry.Tcp -> Icons.Rounded.Wifi
is DeviceListEntry.Mock -> Icons.Rounded.Add
}
val contentDescription =
@@ -65,75 +58,25 @@ fun DeviceListItem(
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 is DeviceListEntry.Disconnect -> {
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) {
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.StatusGreen
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.StatusRed
else ->
MaterialTheme.colorScheme
.onPrimaryContainer // Fallback for other states (e.g. connecting)
},
)
}
else -> {
ListItemDefaults.colors()
}
}
val useSelectable = modifier == Modifier
ListItem(
modifier =
if (useSelectable) {
modifier.fillMaxWidth().selectable(selected = selected, onClick = onSelect)
modifier.fillMaxWidth().clickable(onClick = onSelect)
} else {
modifier.fillMaxWidth()
},
headlineContent = { Text(device.name) },
leadingContent = {
Icon(
icon, // icon is already CloudOff if device.isDisconnect
contentDescription,
)
},
leadingContent = { Icon(icon, contentDescription) },
supportingContent = {
if (device is DeviceListEntry.Tcp) {
Text(device.address)
}
},
trailingContent = {
if (device is DeviceListEntry.Disconnect) {
Icon(imageVector = Icons.Default.CloudOff, contentDescription = stringResource(R.string.disconnect))
} else if (connectionState == ConnectionState.CONNECTED) {
Icon(imageVector = Icons.Default.CloudDone, contentDescription = stringResource(R.string.connected))
} else {
Icon(
imageVector = Icons.Default.CloudQueue,
contentDescription = stringResource(R.string.not_connected),
)
}
},
colors = colors,
trailingContent = { RadioButton(selected = connected, onClick = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}

View File

@@ -0,0 +1,71 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun EmptyStateContent(imageVector: ImageVector? = null, text: String, actionButton: @Composable (() -> Unit)? = null) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
imageVector?.let { Icon(imageVector = imageVector, contentDescription = text, modifier = Modifier.size(96.dp)) }
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
textAlign = TextAlign.Center,
)
actionButton?.invoke()
}
}
@PreviewLightDark
@Composable
fun EmptyStateContentPreview() {
AppTheme {
Surface {
EmptyStateContent(imageVector = Icons.Rounded.BluetoothDisabled, text = "No devices found") {
Button(onClick = {}) { Text("Button") }
}
}
}
}

View File

@@ -17,47 +17,50 @@
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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
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.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldLabelPosition
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewLightDark
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.ConnectionState
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.connections.isIPAddress
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("MagicNumber", "LongMethod")
@Composable
fun NetworkDevices(
@@ -67,138 +70,203 @@ fun NetworkDevices(
selectedDevice: String,
scanModel: BTScanModel,
) {
val manualIpAddress = rememberTextFieldState("")
val manualIpPort = rememberTextFieldState(NetworkRepository.Companion.SERVICE_PORT.toString())
val searchDialogState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showSearchDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var deviceToDelete by remember { mutableStateOf<DeviceListEntry?>(null) }
Text(
text = stringResource(R.string.network),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp),
)
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(
onDismissRequest = { showDeleteDialog = false },
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete_node)) },
confirmButton = {
Button(
onClick = {
scanModel.removeRecentAddress(deviceToDelete!!.fullAddress)
showDeleteDialog = false
},
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
Button(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
if (showSearchDialog) {
AddDeviceDialog(
searchDialogState,
onHideDialog = { showSearchDialog = false },
onClickAdd = { ipAddress, fullAddress ->
scanModel.onSelected(DeviceListEntry.Tcp(ipAddress, fullAddress))
showSearchDialog = false
},
)
}
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),
)
Text(
text = stringResource(R.string.no_network_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
if (showDeleteDialog) {
deviceToDelete?.let {
ConfirmDeleteDialog(
it.fullAddress,
onHideDialog = { showDeleteDialog = false },
onConfirm = { deviceFullAddress -> scanModel.removeRecentAddress(deviceFullAddress) },
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
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
)
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
)
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.toString()
}
scanModel.onSelected(DeviceListEntry.Tcp(manualIpAddress.text.toString(), fullAddress))
Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val addButton: @Composable () -> Unit = {
Button(onClick = { showSearchDialog = true }) {
Icon(imageVector = Icons.Rounded.Add, contentDescription = stringResource(R.string.add_network_device))
Text(stringResource(R.string.add_network_device))
}
}
when {
discoveredNetworkDevices.isEmpty() && recentNetworkDevices.isEmpty() -> {
EmptyStateContent(
imageVector = Icons.Rounded.Wifi,
text = stringResource(R.string.no_network_devices),
actionButton = addButton,
)
}
else -> {
if (recentNetworkDevices.isNotEmpty()) {
TitledCard(title = stringResource(R.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
},
),
)
}
}
}
},
) {
Icon(
imageVector = Icons.Default.WifiFind,
contentDescription = stringResource(R.string.add),
modifier = Modifier.size(32.dp),
)
if (discoveredNetworkDevices.isNotEmpty()) {
TitledCard(title = stringResource(R.string.discovered_network_devices)) {
discoveredNetworkDevices.forEach { device ->
DeviceListItem(
connected =
connectionState == ConnectionState.CONNECTED &&
device.fullAddress == selectedDevice,
device = device,
onSelect = { scanModel.onSelected(device) },
)
}
}
}
addButton()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddDeviceDialog(
sheetState: SheetState,
onHideDialog: () -> Unit,
onClickAdd: (ipAddress: String, fullAddress: String) -> Unit,
) {
val ipState = rememberTextFieldState("")
val portState = rememberTextFieldState(NetworkRepository.SERVICE_PORT.toString())
val scope = rememberCoroutineScope()
@Suppress("MagicNumber")
ModalBottomSheet(onDismissRequest = onHideDialog, sheetState = sheetState) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = ipState,
labelPosition = TextFieldLabelPosition.Above(),
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(R.string.ip_address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f),
)
OutlinedTextField(
state = portState,
labelPosition = TextFieldLabelPosition.Above(),
placeholder = { Text(NetworkRepository.SERVICE_PORT.toString()) },
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(R.string.ip_port)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(.3f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.weight(1f), onClick = { onHideDialog() }) {
Text(stringResource(R.string.cancel))
}
Button(
modifier = Modifier.weight(1f),
onClick = {
val ipAddress = ipState.text.toString()
if (ipAddress.isIPAddress()) {
val portString = portState.text.toString()
val combinedString =
if (portString.isNotEmpty() && portString.toInt() != NetworkRepository.SERVICE_PORT) {
"$ipAddress:$portString"
} else {
ipAddress
}
onClickAdd(ipState.text.toString(), "t$combinedString")
scope
.launch { sheetState.hide() }
.invokeOnCompletion {
if (!sheetState.isVisible) {
onHideDialog()
}
}
}
},
) {
Text(stringResource(R.string.add_network_device))
}
}
}
}
}
@Composable
private fun ConfirmDeleteDialog(
fullAddressToDelete: String,
onHideDialog: () -> Unit,
onConfirm: (deviceFullAddress: String) -> Unit,
) {
AlertDialog(
onDismissRequest = onHideDialog,
title = { Text(stringResource(R.string.delete)) },
text = { Text(stringResource(R.string.confirm_delete_node)) },
confirmButton = {
Button(
onClick = {
onConfirm(fullAddressToDelete)
onHideDialog()
},
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = { Button(onClick = { onHideDialog() }) { Text(stringResource(R.string.cancel)) } },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewLightDark
@Composable
private fun SearchDialogPreview() {
AppTheme {
AddDeviceDialog(sheetState = rememberModalBottomSheetState(), onHideDialog = {}, onClickAdd = { _, _ -> })
}
}
@PreviewLightDark
@Composable
private fun ConfirmDeleteDialogPreview() {
AppTheme { ConfirmDeleteDialog(fullAddressToDelete = "", onHideDialog = {}, onConfirm = {}) }
}

View File

@@ -0,0 +1,54 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun TitledCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
title,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.titleLarge,
)
Card(content = content)
}
}
@PreviewLightDark
@Composable
fun TitledCardPreview() {
AppTheme { Surface { TitledCard(title = "Title") { Box(modifier = Modifier.fillMaxWidth().height(100.dp)) {} } } }
}

View File

@@ -17,24 +17,17 @@
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.UsbOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.rounded.UsbOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.R
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.theme.AppTheme
@Composable
fun UsbDevices(
@@ -43,32 +36,49 @@ fun UsbDevices(
selectedDevice: String,
scanModel: BTScanModel,
) {
Text(
text = stringResource(R.string.serial),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 8.dp),
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
onDeviceSelected = scanModel::onSelected,
)
usbDevices.forEach { device ->
DeviceListItem(
connectionState = connectionState,
device = device,
selected = device.fullAddress == selectedDevice,
onSelect = { scanModel.onSelected(device) },
modifier = Modifier,
)
}
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),
)
Text(
text = stringResource(R.string.no_usb_devices),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
@Composable
private fun UsbDevices(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
onDeviceSelected: (DeviceListEntry) -> Unit,
) {
when {
usbDevices.isEmpty() ->
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(R.string.no_usb_devices))
else ->
TitledCard(title = "") {
usbDevices.forEach { device ->
DeviceListItem(
connected =
connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice,
device = device,
onSelect = { onDeviceSelected(device) },
modifier = Modifier,
)
}
}
}
}
@PreviewLightDark
@Composable
private fun UsbDevicesPreview() {
AppTheme {
UsbDevices(
connectionState = ConnectionState.CONNECTED,
usbDevices = emptyList(),
selectedDevice = "",
onDeviceSelected = {},
)
}
}

View File

@@ -105,7 +105,7 @@
<string name="cancel">Cancel</string>
<string name="clear_changes">Clear changes</string>
<string name="new_channel_rcvd">New Channel URL received</string>
<string name="permission_missing">Meshtastic needs location permission and location must be turned on to find new devices via Bluetooth. You can turn it off again afterwards.</string>
<string name="permission_missing">Meshtastic needs location permissions enabled to find new devices via Bluetooth. You can disable when not in use.</string>
<string name="report_bug">Report Bug</string>
<string name="report_a_bug">Report a bug</string>
<string name="report_bug_text">Are you sure you want to report a bug? After reporting, please post in https://github.com/orgs/meshtastic/discussions so we can match up the report with what you found.</string>
@@ -159,6 +159,7 @@
<string name="save_rangetest">Export rangetest.csv</string>
<string name="reset">Reset</string>
<string name="scan">Scan</string>
<string name="add_network_device">Add</string>
<string name="are_you_sure_change_default">Are you sure you want to change to the default channel?</string>
<string name="reset_to_defaults">Reset to defaults</string>
<string name="apply">Apply</string>
@@ -201,8 +202,10 @@
<string name="quick_chat_show">Show quick chat menu</string>
<string name="quick_chat_hide">Hide quick chat menu</string>
<string name="factory_reset">Factory reset</string>
<string name="bluetooth_disabled">Bluetooth disabled</string>
<string name="permission_missing_31">Meshtastic needs Nearby devices permission to find and connect to devices via Bluetooth. You can turn it off when not in use.</string>
<string name="bluetooth_disabled">Bluetooth is disabled. Please enable it in your device settings.</string>
<string name="open_settings">Open settings</string>
<string name="firmware_version">Firmware version: %1$s</string>
<string name="permission_missing_31">Meshtastic needs \"Nearby devices\" permissions enabled to find and connect to devices via Bluetooth. You can disable when not in use.</string>
<string name="direct_message">Direct Message</string>
<string name="nodedb_reset">NodeDB reset</string>
<string name="delivery_confirmed">Delivery confirmed</string>
@@ -653,7 +656,8 @@
<string name="node_count_template">(%1$d online / %2$d total)</string>
<string name="react">React</string>
<string name="disconnect">Disconnect</string>
<string name="no_ble_devices">No Bluetooth devices found.</string>
<string name="scanning_bluetooth">Scanning for Bluetooth devices</string>
<string name="no_ble_devices">No paired Bluetooth devices.</string>
<string name="no_network_devices">No Network devices found.</string>
<string name="no_usb_devices">No USB Serial devices found.</string>
<string name="scroll_to_bottom">Scroll to bottom</string>
@@ -710,6 +714,9 @@
<string name="no_pax_metrics_logs">No PAX metrics logs available.</string>
<string name="wifi_devices">WiFi Devices</string>
<string name="ble_devices">BLE Devices</string>
<string name="bluetooth_paired_devices">Paired Devices</string>
<string name="connected_device">Connected Device</string>
<string name="action_go">Go</string>
<string name="routing_error_rate_limit_exceeded">Rate Limit Exceeded. Please try again later.</string>
<string name="view_release">View Release</string>
@@ -760,7 +767,7 @@
<string name="configure_critical_alerts">Configure Critical Alerts</string>
<string name="notification_permissions_description">Meshtastic uses notifications to keep you updated on new messages and other important events. You can update your notification permissions at any time from settings.</string>
<string name="next">Next</string>
<string name="grant_permissions_and_scan">Grant Permissions and Scan</string>
<string name="grant_permissions">Grant Permissions</string>
<string name="nodes_queued_for_deletion">%d nodes queued for deletion:</string>
<string name="clean_node_database_description">Caution: This removes nodes from in-app and on-device databases.\nSelections are additive.</string>
<string name="connecting_to_device">Connecting to device</string>