mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-14 09:46:48 -04:00
Clean up Connections UI, fix some friction with Bluetooth (#2807)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}) }
|
||||
}
|
||||
|
||||
@@ -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)) {} } } }
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user