diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 6b252f89d..108c3f724 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -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> = - 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> = @@ -199,12 +189,12 @@ constructor( val usbDevicesForUi: StateFlow> = 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 get() = _spinner.asStateFlow() diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 18f224b19..f0548eab5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -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(), ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 9e0cd86bd..79fdd5412 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -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, 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() } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedCard.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedCard.kt new file mode 100644 index 000000000..a86f946b9 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedCard.kt @@ -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 . + */ + +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 = {}, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt index 89b8a2a3e..ca5f2ed7f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt @@ -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), ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/EmptyStateContent.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/EmptyStateContent.kt new file mode 100644 index 000000000..d5fda4727 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/EmptyStateContent.kt @@ -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 . + */ + +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") } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt index 014add1e1..0e09b4222 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt @@ -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(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 = {}) } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/TitledCard.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/TitledCard.kt new file mode 100644 index 000000000..3eabdb888 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/TitledCard.kt @@ -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 . + */ + +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)) {} } } } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt index 736d1390a..6265afb0b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt @@ -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, + 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 = {}, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a01508ab..4b087d36a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,7 +105,7 @@ Cancel Clear changes New Channel URL received - Meshtastic needs location permission and location must be turned on to find new devices via Bluetooth. You can turn it off again afterwards. + Meshtastic needs location permissions enabled to find new devices via Bluetooth. You can disable when not in use. Report Bug Report a bug 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. @@ -159,6 +159,7 @@ Export rangetest.csv Reset Scan + Add Are you sure you want to change to the default channel? Reset to defaults Apply @@ -201,8 +202,10 @@ Show quick chat menu Hide quick chat menu Factory reset - Bluetooth disabled - Meshtastic needs Nearby devices permission to find and connect to devices via Bluetooth. You can turn it off when not in use. + Bluetooth is disabled. Please enable it in your device settings. + Open settings + Firmware version: %1$s + Meshtastic needs \"Nearby devices\" permissions enabled to find and connect to devices via Bluetooth. You can disable when not in use. Direct Message NodeDB reset Delivery confirmed @@ -653,7 +656,8 @@ (%1$d online / %2$d total) React Disconnect - No Bluetooth devices found. + Scanning for Bluetooth devices… + No paired Bluetooth devices. No Network devices found. No USB Serial devices found. Scroll to bottom @@ -710,6 +714,9 @@ No PAX metrics logs available. WiFi Devices BLE Devices + Paired Devices + Connected Device + Go Rate Limit Exceeded. Please try again later. View Release @@ -760,7 +767,7 @@ Configure Critical Alerts 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. Next - Grant Permissions and Scan + Grant Permissions %d nodes queued for deletion: Caution: This removes nodes from in-app and on-device databases.\nSelections are additive. Connecting to device