From f7efe863f277e3c6125420f2b7e7b3c7bb3a462f Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Sat, 21 Dec 2024 10:40:15 -0800 Subject: [PATCH 1/6] refactor: implement settings screen compose ui --- .../java/com/geeksville/mesh/MainActivity.kt | 11 +- .../repository/location/LocationRepository.kt | 3 + .../geeksville/mesh/ui/SettingsFragment.kt | 12 +- .../com/geeksville/mesh/ui/SettingsScreen.kt | 275 ++++++++++++++++++ .../mesh/ui/SettingsScreenViewModel.kt | 112 +++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 408 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3b70b5c61..f9007044f 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -185,6 +185,12 @@ class MainActivity : AppCompatActivity(), Logging { data class TabInfo(val text: String, val icon: Int, val content: Fragment) private val tabInfos = arrayOf( + // TODO - Remember to return the original order + TabInfo( + "Settings", + R.drawable.ic_twotone_settings_applications_24, + SettingsFragment() + ), TabInfo( "Messages", R.drawable.ic_twotone_message_24, @@ -205,11 +211,6 @@ class MainActivity : AppCompatActivity(), Logging { R.drawable.ic_twotone_contactless_24, ChannelFragment() ), - TabInfo( - "Settings", - R.drawable.ic_twotone_settings_applications_24, - SettingsFragment() - ) ) private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt index f4f65fc20..06a570d69 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt @@ -29,6 +29,7 @@ import androidx.core.location.LocationRequestCompat import androidx.core.location.altitude.AltitudeConverterCompat import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.android.hasGps import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose @@ -115,4 +116,6 @@ class LocationRepository @Inject constructor( */ @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) fun getLocations() = locationManager.get().requestLocationUpdates() + + fun hasGps(): Boolean = context.hasGps() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 0bd04085f..e844bdfad 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -34,6 +34,8 @@ import android.widget.RadioButton import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.activityViewModels import androidx.lifecycle.asLiveData @@ -47,6 +49,7 @@ import com.geeksville.mesh.model.RegionInfo import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.exceptionToSnackbar import com.geeksville.mesh.util.onEditorAction import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -75,7 +78,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { savedInstanceState: Bundle? ): View { _binding = SettingsFragmentBinding.inflate(inflater, container, false) - return binding.root + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + SettingsScreen() + } + } + } } /** diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt new file mode 100644 index 000000000..8d8831407 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -0,0 +1,275 @@ +package com.geeksville.mesh.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.geeksville.mesh.R +import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.ui.theme.AppTheme + + +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier, + viewModel: SettingsScreenViewModel = hiltViewModel(), + btScanModel: BTScanModel = hiltViewModel(), +) { + LaunchedEffect(btScanModel.devices.value?.values, viewModel.connectionState) { + viewModel.updateNodeInfo() + } + Surface { + Column(modifier = modifier.padding(16.dp)) { + if (viewModel.showNodeSettings) { + NameAndRegionRow( + textValue = viewModel.userName, + onValueChange = viewModel::onUserNameChange, + dropDownExpanded = viewModel.regionDropDownExpanded, + onToggleDropDown = viewModel::onToggleRegionDropDown, + selectedRegion = viewModel.selectedRegion, + onRegionSelected = viewModel::onRegionSelected, + ) + } + RadioConnectionStatusMessage() + RadioSelectorRadioButtons( + devices = btScanModel.devices.value?.values?.toList() ?: emptyList(), + onDeviceSelected = { btScanModel.onSelected(it) }, + selectedAddress = btScanModel.selectedNotNull, + + ) + AddDeviceByIPAddress( + ipAddress = viewModel.ipAddress, + onIpAddressChange = viewModel::onIpAddressChange + ) + if (viewModel.showProvideLocation) { + ProvideLocationCheckBox(enabled = viewModel.enableProvideLocation) + } + + Text( + text = stringResource(R.string.warning_not_paired), + style = MaterialTheme.typography.caption, + modifier = Modifier + .padding(top = 16.dp) + .alpha(0.7f) + ) + + Spacer(modifier = Modifier.weight(1f)) + + ExtendedFloatingActionButton( + onClick = { /*TODO*/ }, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_radio), + tint = Color.White + ) + }, + text = { + Text( + stringResource(R.string.add_radio), + color = Color.White + ) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun NameAndRegionRow( + modifier: Modifier = Modifier, + textValue: String, + onValueChange: (String) -> Unit, + dropDownExpanded: Boolean, + onToggleDropDown: () -> Unit, + selectedRegion: RegionInfo, + onRegionSelected: (RegionInfo) -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = textValue, + onValueChange = onValueChange, + label = { Text(stringResource(R.string.your_name)) }, + singleLine = true, + modifier = Modifier, + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ) + ) + RegionSelector( + dropDownExpanded = dropDownExpanded, + onToggleDropDown = onToggleDropDown, + selectedRegion = selectedRegion, + onRegionSelected = onRegionSelected, + ) + } +} + +@Composable +private fun RegionSelector( + modifier: Modifier = Modifier, + dropDownExpanded: Boolean = false, + onToggleDropDown: () -> Unit, + selectedRegion: RegionInfo, + onRegionSelected: (RegionInfo) -> Unit, +) { + Box( + modifier = modifier + .padding(horizontal = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleDropDown), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = selectedRegion.regionCode.name, + style = MaterialTheme.typography.body1 + ) + + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colors.onSurface, + ) + } + DropdownMenu( + expanded = dropDownExpanded, + onDismissRequest = { onToggleDropDown() }, + modifier = Modifier + .background(MaterialTheme.colors.surface, shape = RoundedCornerShape(16.dp)) + ) { + for (region in RegionInfo.entries) { + DropdownMenuItem(onClick = { onRegionSelected(region) }) { + Text(region.name) + } + } + } + } +} + +@Composable +private fun RadioSelectorRadioButtons( + modifier: Modifier = Modifier, + devices: List, + onDeviceSelected: (BTScanModel.DeviceListEntry) -> Unit, + selectedAddress: String, +) { + Column(modifier = modifier.padding(vertical = 8.dp)) { + repeat(devices.size) { + val device = devices[it] + Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedAddress == device.fullAddress, + onClick = { onDeviceSelected(device) }) + Text(device.name) + } + } + } +} + +@Composable +fun AddDeviceByIPAddress( + modifier: Modifier = Modifier, + ipAddress: String = "", + onIpAddressChange: (String) -> Unit +) { + Column(modifier = modifier) { + Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { + RadioButton( + enabled = false, + selected = false, + onClick = { /*TODO*/ }) + Text(stringResource(R.string.ip_address), modifier = Modifier.alpha(0.5f)) + } + TextField( + value = ipAddress, + onValueChange = onIpAddressChange, + label = { Text(stringResource(R.string.ip_address)) }, + placeholder = { Text(stringResource(R.string.ip_address)) }, + singleLine = true, + modifier = modifier.padding(vertical = 8.dp), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ) + ) + } +} + +@Composable +private fun ProvideLocationCheckBox(modifier: Modifier = Modifier, enabled: Boolean) { + Row( + modifier = modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + enabled = enabled, + checked = true, + onCheckedChange = { /*TODO*/ }, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(R.string.provide_location_to_mesh)) + } +} + +@Composable +private fun RadioConnectionStatusMessage(modifier: Modifier = Modifier) { + // TODO - add condition for if we are paired or not + Text(stringResource(R.string.not_paired_yet)) +} + + +@Preview +@Composable +private fun SettingsScreenPreview() { + AppTheme { Surface { SettingsScreen() } } +} + +@Preview +@Composable +private fun SettingsScreenPreviewDark() { + AppTheme(darkTheme = true) { Surface { SettingsScreen() } } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt new file mode 100644 index 000000000..3b249e48e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -0,0 +1,112 @@ +package com.geeksville.mesh.ui + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.R +import com.geeksville.mesh.database.NodeRepository +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.repository.location.LocationRepository +import com.geeksville.mesh.service.MeshService +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SettingsScreenViewModel @Inject constructor( + private val application: Application, + private val radioConfigRepository: RadioConfigRepository, + private val locationRepository: LocationRepository, + private val nodeRepository: NodeRepository, +) : ViewModel() { + + var userName by mutableStateOf("") + private set + + fun onUserNameChange(newName: String) { + userName = newName + } + + var regionDropDownExpanded by mutableStateOf(false) + private set + + fun onToggleRegionDropDown() { + regionDropDownExpanded = !regionDropDownExpanded + } + + var selectedRegion by mutableStateOf(RegionInfo.UNSET) + private set + + fun onRegionSelected(newRegion: RegionInfo) { + selectedRegion = newRegion + onToggleRegionDropDown() + } + + + var localConfig by mutableStateOf(LocalConfig.getDefaultInstance()) + private set + var showNodeSettings by mutableStateOf(false) + private set + var showProvideLocation by mutableStateOf(false) + private set + var enableUsernameEdit by mutableStateOf(false) + private set + var enableProvideLocation by mutableStateOf(false) + private set + + // managed mode disables all access to configuration + val isManaged: Boolean get() = localConfig.device.isManaged || localConfig.security.isManaged + + var errorText by mutableStateOf(null as String?) + private set + + val connectionState get() = radioConfigRepository.connectionState.value + /** + * Pull the latest device info from the model and into the GUI + */ + fun updateNodeInfo() { + val connectionState = radioConfigRepository.connectionState.value + val isConnected = connectionState == MeshService.ConnectionState.CONNECTED + + showNodeSettings = isConnected + showProvideLocation = isConnected + + enableUsernameEdit = isConnected && !isManaged + + enableProvideLocation = locationRepository.hasGps() + + // update the region selection from the device + val region = localConfig.lora.region + + selectedRegion = + RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET + + // Update the status string (highest priority messages first) + val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val info = nodeRepository.myNodeInfo.value + when (connectionState) { + MeshService.ConnectionState.CONNECTED -> + if (regionUnset) R.string.must_set_region else R.string.connected_to + + MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected + MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping + else -> null + }?.let { + errorText = info?.firmwareString ?: application.resources.getString(R.string.unknown) + } + } + + + var ipAddress by mutableStateOf("") + private set + + fun onIpAddressChange(newAddress: String) { + ipAddress = newAddress + } +} + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63975f30e..f44e3260b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,6 +312,7 @@ Selected Not Selected Unknown Age + Add Radio Copy Alert Bell Character! From 82122cac0d064c89f06ba193b2b61b18dee94ce5 Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Wed, 25 Dec 2024 18:38:08 -0800 Subject: [PATCH 2/6] refactor: linked radio settings ui to viewmodel --- .../com/geeksville/mesh/model/BTScanModel.kt | 73 ++++++++++--------- .../geeksville/mesh/ui/SettingsFragment.kt | 6 +- .../com/geeksville/mesh/ui/SettingsScreen.kt | 6 +- .../mesh/ui/SettingsScreenViewModel.kt | 67 ++++++++++++++--- 4 files changed, 98 insertions(+), 54 deletions(-) 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 b091fa184..f71c1e381 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -24,12 +24,13 @@ import android.content.Context import android.hardware.usb.UsbManager import android.net.nsd.NsdServiceInfo import android.os.RemoteException +import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.radio.InterfaceId @@ -60,7 +61,7 @@ class BTScanModel @Inject constructor( ) : ViewModel(), Logging { private val context: Context get() = application.applicationContext - val devices = MutableLiveData>(mutableMapOf()) + val devices = mutableStateMapOf() val errorText = MutableLiveData(null) private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface) @@ -76,27 +77,27 @@ class BTScanModel @Inject constructor( usbRepository.serialDevicesWithDrivers, showMockInterface, ) { ble, tcp, usb, showMockInterface -> - devices.value = mutableMapOf().apply { - fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry } + fun addDevice(entry: DeviceListEntry) { + devices.put(entry.fullAddress, entry) + } - // Include a placeholder for "None" - addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + // Include a placeholder for "None" + addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) - if (showMockInterface) { - addDevice(DeviceListEntry("Demo Mode", "m", true)) - } + if (showMockInterface) { + addDevice(DeviceListEntry("Demo Mode", "m", true)) + } - // Include paired Bluetooth devices - ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice) + // Include paired Bluetooth devices + ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice) - // Include Network Service Discovery - tcp.forEach { service -> - addDevice(TCPDeviceListEntry(service)) - } + // Include Network Service Discovery + tcp.forEach { service -> + addDevice(TCPDeviceListEntry(service)) + } - usb.forEach { (_, d) -> - addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) - } + usb.forEach { (_, d) -> + addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) } }.launchIn(viewModelScope) @@ -191,23 +192,23 @@ class BTScanModel @Inject constructor( _spinner.value = true scanJob = bluetoothRepository.scan() .onEach { result -> - val fullAddress = radioInterfaceService.toInterfaceAddress( - InterfaceId.BLUETOOTH, - result.device.address - ) - // prevent log spam because we'll get lots of redundant scan results - val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED - val oldDevs = scanResult.value!! - val oldEntry = oldDevs[fullAddress] - // Don't spam the GUI with endless updates for non changing nodes - if (oldEntry == null || oldEntry.bonded != isBonded) { - val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) - oldDevs[entry.fullAddress] = entry - scanResult.value = oldDevs - } - }.catch { ex -> - serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") - }.launchIn(viewModelScope) + val fullAddress = radioInterfaceService.toInterfaceAddress( + InterfaceId.BLUETOOTH, + result.device.address + ) + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = scanResult.value!! + val oldEntry = oldDevs[fullAddress] + // Don't spam the GUI with endless updates for non changing nodes + if (oldEntry == null || oldEntry.bonded != isBonded) { + val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + oldDevs[entry.fullAddress] = entry + scanResult.value = oldDevs + } + }.catch { ex -> + serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") + }.launchIn(viewModelScope) } private fun changeDeviceAddress(address: String) { @@ -215,7 +216,7 @@ class BTScanModel @Inject constructor( serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } - devices.value = devices.value // Force a GUI update +// devices.value = devices.value // Force a GUI update } catch (ex: RemoteException) { errormsg("changeDeviceSelection failed, probably it is shutting down", ex) // ignore the failure and the GUI won't be updating anyways diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index e844bdfad..d1aae0f8b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -205,9 +205,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { binding.usernameEditText.setText(node?.user?.longName.orEmpty()) } - scanModel.devices.observe(viewLifecycleOwner) { devices -> - updateDevicesButtons(devices) - } +// scanModel.devices.observe(viewLifecycleOwner) { devices -> +// updateDevicesButtons(devices) +// } // Only let user edit their name or set software update while connected to a radio model.connectionState.asLiveData().observe(viewLifecycleOwner) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt index 8d8831407..def4825bb 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -46,7 +46,7 @@ fun SettingsScreen( viewModel: SettingsScreenViewModel = hiltViewModel(), btScanModel: BTScanModel = hiltViewModel(), ) { - LaunchedEffect(btScanModel.devices.value?.values, viewModel.connectionState) { + LaunchedEffect(btScanModel.devices, viewModel.isConnected) { viewModel.updateNodeInfo() } Surface { @@ -63,7 +63,7 @@ fun SettingsScreen( } RadioConnectionStatusMessage() RadioSelectorRadioButtons( - devices = btScanModel.devices.value?.values?.toList() ?: emptyList(), + devices = btScanModel.devices.values.toList(), onDeviceSelected = { btScanModel.onSelected(it) }, selectedAddress = btScanModel.selectedNotNull, @@ -258,7 +258,7 @@ private fun ProvideLocationCheckBox(modifier: Modifier = Modifier, enabled: Bool @Composable private fun RadioConnectionStatusMessage(modifier: Modifier = Modifier) { // TODO - add condition for if we are paired or not - Text(stringResource(R.string.not_paired_yet)) + Text(stringResource(R.string.not_paired_yet), modifier = modifier) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt index 3b249e48e..95e47f933 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -1,19 +1,23 @@ package com.geeksville.mesh.ui import android.app.Application +import android.os.RemoteException import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import com.geeksville.mesh.ConfigProtos +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.ConfigProtos.Config import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.R +import com.geeksville.mesh.config import com.geeksville.mesh.database.NodeRepository import com.geeksville.mesh.model.RegionInfo import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -44,6 +48,7 @@ class SettingsScreenViewModel @Inject constructor( fun onRegionSelected(newRegion: RegionInfo) { selectedRegion = newRegion onToggleRegionDropDown() + updateLoraConfig { it.toBuilder().setRegion(newRegion.regionCode).build() } } @@ -64,18 +69,40 @@ class SettingsScreenViewModel @Inject constructor( var errorText by mutableStateOf(null as String?) private set - val connectionState get() = radioConfigRepository.connectionState.value + var isConnected = false + private set + + init { + viewModelScope.launch { + radioConfigRepository.connectionState.collect { connectionState -> + isConnected = connectionState == MeshService.ConnectionState.CONNECTED + showNodeSettings = isConnected + showProvideLocation = isConnected + enableUsernameEdit = isConnected && !isManaged + } + } + + viewModelScope.launch { + nodeRepository.ourNodeInfo.collect { node -> + userName = node?.user?.longName ?: userName + } + } + + viewModelScope.launch { + radioConfigRepository.localConfigFlow.collect { + localConfig = it + selectedRegion = localConfig.lora.region.let { region -> + RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET + } + } + } + } + /** * Pull the latest device info from the model and into the GUI */ - fun updateNodeInfo() { - val connectionState = radioConfigRepository.connectionState.value - val isConnected = connectionState == MeshService.ConnectionState.CONNECTED - - showNodeSettings = isConnected - showProvideLocation = isConnected + suspend fun updateNodeInfo() { - enableUsernameEdit = isConnected && !isManaged enableProvideLocation = locationRepository.hasGps() @@ -86,9 +113,9 @@ class SettingsScreenViewModel @Inject constructor( RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET // Update the status string (highest priority messages first) - val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val regionUnset = region == Config.LoRaConfig.RegionCode.UNSET val info = nodeRepository.myNodeInfo.value - when (connectionState) { + when (radioConfigRepository.connectionState.value) { MeshService.ConnectionState.CONNECTED -> if (regionUnset) R.string.must_set_region else R.string.connected_to @@ -98,8 +125,8 @@ class SettingsScreenViewModel @Inject constructor( }?.let { errorText = info?.firmwareString ?: application.resources.getString(R.string.unknown) } - } + } var ipAddress by mutableStateOf("") private set @@ -107,6 +134,22 @@ class SettingsScreenViewModel @Inject constructor( fun onIpAddressChange(newAddress: String) { ipAddress = newAddress } + + + private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { + val data = body(localConfig.lora) + setConfig(config { lora = data }) + } + + // Set the radio config (also updates our saved copy in preferences) + private fun setConfig(config: Config) { + try { + radioConfigRepository.meshService?.setConfig(config.toByteArray()) + } catch (ex: RemoteException) { + // TODO: Show error message to ui + } + } + } From 723c571d05472ada9bd09e290494e209ceafc22a Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Sat, 21 Dec 2024 10:40:15 -0800 Subject: [PATCH 3/6] refactor: implement settings screen compose ui --- .../java/com/geeksville/mesh/MainActivity.kt | 11 +- .../repository/location/LocationRepository.kt | 3 + .../geeksville/mesh/ui/SettingsFragment.kt | 12 +- .../com/geeksville/mesh/ui/SettingsScreen.kt | 275 ++++++++++++++++++ .../mesh/ui/SettingsScreenViewModel.kt | 112 +++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 408 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3b70b5c61..f9007044f 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -185,6 +185,12 @@ class MainActivity : AppCompatActivity(), Logging { data class TabInfo(val text: String, val icon: Int, val content: Fragment) private val tabInfos = arrayOf( + // TODO - Remember to return the original order + TabInfo( + "Settings", + R.drawable.ic_twotone_settings_applications_24, + SettingsFragment() + ), TabInfo( "Messages", R.drawable.ic_twotone_message_24, @@ -205,11 +211,6 @@ class MainActivity : AppCompatActivity(), Logging { R.drawable.ic_twotone_contactless_24, ChannelFragment() ), - TabInfo( - "Settings", - R.drawable.ic_twotone_settings_applications_24, - SettingsFragment() - ) ) private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt index f4f65fc20..06a570d69 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/location/LocationRepository.kt @@ -29,6 +29,7 @@ import androidx.core.location.LocationRequestCompat import androidx.core.location.altitude.AltitudeConverterCompat import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.android.hasGps import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose @@ -115,4 +116,6 @@ class LocationRepository @Inject constructor( */ @RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION]) fun getLocations() = locationManager.get().requestLocationUpdates() + + fun hasGps(): Boolean = context.hasGps() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 0bd04085f..e844bdfad 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -34,6 +34,8 @@ import android.widget.RadioButton import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.activityViewModels import androidx.lifecycle.asLiveData @@ -47,6 +49,7 @@ import com.geeksville.mesh.model.RegionInfo import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.exceptionToSnackbar import com.geeksville.mesh.util.onEditorAction import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -75,7 +78,14 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { savedInstanceState: Bundle? ): View { _binding = SettingsFragmentBinding.inflate(inflater, container, false) - return binding.root + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + SettingsScreen() + } + } + } } /** diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt new file mode 100644 index 000000000..8d8831407 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -0,0 +1,275 @@ +package com.geeksville.mesh.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.geeksville.mesh.R +import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.ui.theme.AppTheme + + +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier, + viewModel: SettingsScreenViewModel = hiltViewModel(), + btScanModel: BTScanModel = hiltViewModel(), +) { + LaunchedEffect(btScanModel.devices.value?.values, viewModel.connectionState) { + viewModel.updateNodeInfo() + } + Surface { + Column(modifier = modifier.padding(16.dp)) { + if (viewModel.showNodeSettings) { + NameAndRegionRow( + textValue = viewModel.userName, + onValueChange = viewModel::onUserNameChange, + dropDownExpanded = viewModel.regionDropDownExpanded, + onToggleDropDown = viewModel::onToggleRegionDropDown, + selectedRegion = viewModel.selectedRegion, + onRegionSelected = viewModel::onRegionSelected, + ) + } + RadioConnectionStatusMessage() + RadioSelectorRadioButtons( + devices = btScanModel.devices.value?.values?.toList() ?: emptyList(), + onDeviceSelected = { btScanModel.onSelected(it) }, + selectedAddress = btScanModel.selectedNotNull, + + ) + AddDeviceByIPAddress( + ipAddress = viewModel.ipAddress, + onIpAddressChange = viewModel::onIpAddressChange + ) + if (viewModel.showProvideLocation) { + ProvideLocationCheckBox(enabled = viewModel.enableProvideLocation) + } + + Text( + text = stringResource(R.string.warning_not_paired), + style = MaterialTheme.typography.caption, + modifier = Modifier + .padding(top = 16.dp) + .alpha(0.7f) + ) + + Spacer(modifier = Modifier.weight(1f)) + + ExtendedFloatingActionButton( + onClick = { /*TODO*/ }, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_radio), + tint = Color.White + ) + }, + text = { + Text( + stringResource(R.string.add_radio), + color = Color.White + ) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun NameAndRegionRow( + modifier: Modifier = Modifier, + textValue: String, + onValueChange: (String) -> Unit, + dropDownExpanded: Boolean, + onToggleDropDown: () -> Unit, + selectedRegion: RegionInfo, + onRegionSelected: (RegionInfo) -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = textValue, + onValueChange = onValueChange, + label = { Text(stringResource(R.string.your_name)) }, + singleLine = true, + modifier = Modifier, + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ) + ) + RegionSelector( + dropDownExpanded = dropDownExpanded, + onToggleDropDown = onToggleDropDown, + selectedRegion = selectedRegion, + onRegionSelected = onRegionSelected, + ) + } +} + +@Composable +private fun RegionSelector( + modifier: Modifier = Modifier, + dropDownExpanded: Boolean = false, + onToggleDropDown: () -> Unit, + selectedRegion: RegionInfo, + onRegionSelected: (RegionInfo) -> Unit, +) { + Box( + modifier = modifier + .padding(horizontal = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggleDropDown), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = selectedRegion.regionCode.name, + style = MaterialTheme.typography.body1 + ) + + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colors.onSurface, + ) + } + DropdownMenu( + expanded = dropDownExpanded, + onDismissRequest = { onToggleDropDown() }, + modifier = Modifier + .background(MaterialTheme.colors.surface, shape = RoundedCornerShape(16.dp)) + ) { + for (region in RegionInfo.entries) { + DropdownMenuItem(onClick = { onRegionSelected(region) }) { + Text(region.name) + } + } + } + } +} + +@Composable +private fun RadioSelectorRadioButtons( + modifier: Modifier = Modifier, + devices: List, + onDeviceSelected: (BTScanModel.DeviceListEntry) -> Unit, + selectedAddress: String, +) { + Column(modifier = modifier.padding(vertical = 8.dp)) { + repeat(devices.size) { + val device = devices[it] + Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selectedAddress == device.fullAddress, + onClick = { onDeviceSelected(device) }) + Text(device.name) + } + } + } +} + +@Composable +fun AddDeviceByIPAddress( + modifier: Modifier = Modifier, + ipAddress: String = "", + onIpAddressChange: (String) -> Unit +) { + Column(modifier = modifier) { + Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { + RadioButton( + enabled = false, + selected = false, + onClick = { /*TODO*/ }) + Text(stringResource(R.string.ip_address), modifier = Modifier.alpha(0.5f)) + } + TextField( + value = ipAddress, + onValueChange = onIpAddressChange, + label = { Text(stringResource(R.string.ip_address)) }, + placeholder = { Text(stringResource(R.string.ip_address)) }, + singleLine = true, + modifier = modifier.padding(vertical = 8.dp), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ) + ) + } +} + +@Composable +private fun ProvideLocationCheckBox(modifier: Modifier = Modifier, enabled: Boolean) { + Row( + modifier = modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + enabled = enabled, + checked = true, + onCheckedChange = { /*TODO*/ }, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(R.string.provide_location_to_mesh)) + } +} + +@Composable +private fun RadioConnectionStatusMessage(modifier: Modifier = Modifier) { + // TODO - add condition for if we are paired or not + Text(stringResource(R.string.not_paired_yet)) +} + + +@Preview +@Composable +private fun SettingsScreenPreview() { + AppTheme { Surface { SettingsScreen() } } +} + +@Preview +@Composable +private fun SettingsScreenPreviewDark() { + AppTheme(darkTheme = true) { Surface { SettingsScreen() } } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt new file mode 100644 index 000000000..3b249e48e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -0,0 +1,112 @@ +package com.geeksville.mesh.ui + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.LocalOnlyProtos.LocalConfig +import com.geeksville.mesh.R +import com.geeksville.mesh.database.NodeRepository +import com.geeksville.mesh.model.RegionInfo +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.repository.location.LocationRepository +import com.geeksville.mesh.service.MeshService +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SettingsScreenViewModel @Inject constructor( + private val application: Application, + private val radioConfigRepository: RadioConfigRepository, + private val locationRepository: LocationRepository, + private val nodeRepository: NodeRepository, +) : ViewModel() { + + var userName by mutableStateOf("") + private set + + fun onUserNameChange(newName: String) { + userName = newName + } + + var regionDropDownExpanded by mutableStateOf(false) + private set + + fun onToggleRegionDropDown() { + regionDropDownExpanded = !regionDropDownExpanded + } + + var selectedRegion by mutableStateOf(RegionInfo.UNSET) + private set + + fun onRegionSelected(newRegion: RegionInfo) { + selectedRegion = newRegion + onToggleRegionDropDown() + } + + + var localConfig by mutableStateOf(LocalConfig.getDefaultInstance()) + private set + var showNodeSettings by mutableStateOf(false) + private set + var showProvideLocation by mutableStateOf(false) + private set + var enableUsernameEdit by mutableStateOf(false) + private set + var enableProvideLocation by mutableStateOf(false) + private set + + // managed mode disables all access to configuration + val isManaged: Boolean get() = localConfig.device.isManaged || localConfig.security.isManaged + + var errorText by mutableStateOf(null as String?) + private set + + val connectionState get() = radioConfigRepository.connectionState.value + /** + * Pull the latest device info from the model and into the GUI + */ + fun updateNodeInfo() { + val connectionState = radioConfigRepository.connectionState.value + val isConnected = connectionState == MeshService.ConnectionState.CONNECTED + + showNodeSettings = isConnected + showProvideLocation = isConnected + + enableUsernameEdit = isConnected && !isManaged + + enableProvideLocation = locationRepository.hasGps() + + // update the region selection from the device + val region = localConfig.lora.region + + selectedRegion = + RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET + + // Update the status string (highest priority messages first) + val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val info = nodeRepository.myNodeInfo.value + when (connectionState) { + MeshService.ConnectionState.CONNECTED -> + if (regionUnset) R.string.must_set_region else R.string.connected_to + + MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected + MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping + else -> null + }?.let { + errorText = info?.firmwareString ?: application.resources.getString(R.string.unknown) + } + } + + + var ipAddress by mutableStateOf("") + private set + + fun onIpAddressChange(newAddress: String) { + ipAddress = newAddress + } +} + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63975f30e..f44e3260b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,6 +312,7 @@ Selected Not Selected Unknown Age + Add Radio Copy Alert Bell Character! From 96cee0e4b2d273dfef34bf172490c20874f33514 Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Wed, 25 Dec 2024 18:38:08 -0800 Subject: [PATCH 4/6] refactor: linked radio settings ui to viewmodel --- .../com/geeksville/mesh/model/BTScanModel.kt | 73 ++++++++++--------- .../geeksville/mesh/ui/SettingsFragment.kt | 6 +- .../com/geeksville/mesh/ui/SettingsScreen.kt | 6 +- .../mesh/ui/SettingsScreenViewModel.kt | 67 ++++++++++++++--- 4 files changed, 98 insertions(+), 54 deletions(-) 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 b091fa184..f71c1e381 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -24,12 +24,13 @@ import android.content.Context import android.hardware.usb.UsbManager import android.net.nsd.NsdServiceInfo import android.os.RemoteException +import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.radio.InterfaceId @@ -60,7 +61,7 @@ class BTScanModel @Inject constructor( ) : ViewModel(), Logging { private val context: Context get() = application.applicationContext - val devices = MutableLiveData>(mutableMapOf()) + val devices = mutableStateMapOf() val errorText = MutableLiveData(null) private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface) @@ -76,27 +77,27 @@ class BTScanModel @Inject constructor( usbRepository.serialDevicesWithDrivers, showMockInterface, ) { ble, tcp, usb, showMockInterface -> - devices.value = mutableMapOf().apply { - fun addDevice(entry: DeviceListEntry) { this[entry.fullAddress] = entry } + fun addDevice(entry: DeviceListEntry) { + devices.put(entry.fullAddress, entry) + } - // Include a placeholder for "None" - addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) + // Include a placeholder for "None" + addDevice(DeviceListEntry(context.getString(R.string.none), "n", true)) - if (showMockInterface) { - addDevice(DeviceListEntry("Demo Mode", "m", true)) - } + if (showMockInterface) { + addDevice(DeviceListEntry("Demo Mode", "m", true)) + } - // Include paired Bluetooth devices - ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice) + // Include paired Bluetooth devices + ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice) - // Include Network Service Discovery - tcp.forEach { service -> - addDevice(TCPDeviceListEntry(service)) - } + // Include Network Service Discovery + tcp.forEach { service -> + addDevice(TCPDeviceListEntry(service)) + } - usb.forEach { (_, d) -> - addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) - } + usb.forEach { (_, d) -> + addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) } }.launchIn(viewModelScope) @@ -191,23 +192,23 @@ class BTScanModel @Inject constructor( _spinner.value = true scanJob = bluetoothRepository.scan() .onEach { result -> - val fullAddress = radioInterfaceService.toInterfaceAddress( - InterfaceId.BLUETOOTH, - result.device.address - ) - // prevent log spam because we'll get lots of redundant scan results - val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED - val oldDevs = scanResult.value!! - val oldEntry = oldDevs[fullAddress] - // Don't spam the GUI with endless updates for non changing nodes - if (oldEntry == null || oldEntry.bonded != isBonded) { - val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) - oldDevs[entry.fullAddress] = entry - scanResult.value = oldDevs - } - }.catch { ex -> - serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") - }.launchIn(viewModelScope) + val fullAddress = radioInterfaceService.toInterfaceAddress( + InterfaceId.BLUETOOTH, + result.device.address + ) + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = scanResult.value!! + val oldEntry = oldDevs[fullAddress] + // Don't spam the GUI with endless updates for non changing nodes + if (oldEntry == null || oldEntry.bonded != isBonded) { + val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + oldDevs[entry.fullAddress] = entry + scanResult.value = oldDevs + } + }.catch { ex -> + serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") + }.launchIn(viewModelScope) } private fun changeDeviceAddress(address: String) { @@ -215,7 +216,7 @@ class BTScanModel @Inject constructor( serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } - devices.value = devices.value // Force a GUI update +// devices.value = devices.value // Force a GUI update } catch (ex: RemoteException) { errormsg("changeDeviceSelection failed, probably it is shutting down", ex) // ignore the failure and the GUI won't be updating anyways diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index e844bdfad..d1aae0f8b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -205,9 +205,9 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { binding.usernameEditText.setText(node?.user?.longName.orEmpty()) } - scanModel.devices.observe(viewLifecycleOwner) { devices -> - updateDevicesButtons(devices) - } +// scanModel.devices.observe(viewLifecycleOwner) { devices -> +// updateDevicesButtons(devices) +// } // Only let user edit their name or set software update while connected to a radio model.connectionState.asLiveData().observe(viewLifecycleOwner) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt index 8d8831407..def4825bb 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -46,7 +46,7 @@ fun SettingsScreen( viewModel: SettingsScreenViewModel = hiltViewModel(), btScanModel: BTScanModel = hiltViewModel(), ) { - LaunchedEffect(btScanModel.devices.value?.values, viewModel.connectionState) { + LaunchedEffect(btScanModel.devices, viewModel.isConnected) { viewModel.updateNodeInfo() } Surface { @@ -63,7 +63,7 @@ fun SettingsScreen( } RadioConnectionStatusMessage() RadioSelectorRadioButtons( - devices = btScanModel.devices.value?.values?.toList() ?: emptyList(), + devices = btScanModel.devices.values.toList(), onDeviceSelected = { btScanModel.onSelected(it) }, selectedAddress = btScanModel.selectedNotNull, @@ -258,7 +258,7 @@ private fun ProvideLocationCheckBox(modifier: Modifier = Modifier, enabled: Bool @Composable private fun RadioConnectionStatusMessage(modifier: Modifier = Modifier) { // TODO - add condition for if we are paired or not - Text(stringResource(R.string.not_paired_yet)) + Text(stringResource(R.string.not_paired_yet), modifier = modifier) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt index 3b249e48e..95e47f933 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -1,19 +1,23 @@ package com.geeksville.mesh.ui import android.app.Application +import android.os.RemoteException import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import com.geeksville.mesh.ConfigProtos +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.ConfigProtos.Config import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.R +import com.geeksville.mesh.config import com.geeksville.mesh.database.NodeRepository import com.geeksville.mesh.model.RegionInfo import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -44,6 +48,7 @@ class SettingsScreenViewModel @Inject constructor( fun onRegionSelected(newRegion: RegionInfo) { selectedRegion = newRegion onToggleRegionDropDown() + updateLoraConfig { it.toBuilder().setRegion(newRegion.regionCode).build() } } @@ -64,18 +69,40 @@ class SettingsScreenViewModel @Inject constructor( var errorText by mutableStateOf(null as String?) private set - val connectionState get() = radioConfigRepository.connectionState.value + var isConnected = false + private set + + init { + viewModelScope.launch { + radioConfigRepository.connectionState.collect { connectionState -> + isConnected = connectionState == MeshService.ConnectionState.CONNECTED + showNodeSettings = isConnected + showProvideLocation = isConnected + enableUsernameEdit = isConnected && !isManaged + } + } + + viewModelScope.launch { + nodeRepository.ourNodeInfo.collect { node -> + userName = node?.user?.longName ?: userName + } + } + + viewModelScope.launch { + radioConfigRepository.localConfigFlow.collect { + localConfig = it + selectedRegion = localConfig.lora.region.let { region -> + RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET + } + } + } + } + /** * Pull the latest device info from the model and into the GUI */ - fun updateNodeInfo() { - val connectionState = radioConfigRepository.connectionState.value - val isConnected = connectionState == MeshService.ConnectionState.CONNECTED - - showNodeSettings = isConnected - showProvideLocation = isConnected + suspend fun updateNodeInfo() { - enableUsernameEdit = isConnected && !isManaged enableProvideLocation = locationRepository.hasGps() @@ -86,9 +113,9 @@ class SettingsScreenViewModel @Inject constructor( RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET // Update the status string (highest priority messages first) - val regionUnset = region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET + val regionUnset = region == Config.LoRaConfig.RegionCode.UNSET val info = nodeRepository.myNodeInfo.value - when (connectionState) { + when (radioConfigRepository.connectionState.value) { MeshService.ConnectionState.CONNECTED -> if (regionUnset) R.string.must_set_region else R.string.connected_to @@ -98,8 +125,8 @@ class SettingsScreenViewModel @Inject constructor( }?.let { errorText = info?.firmwareString ?: application.resources.getString(R.string.unknown) } - } + } var ipAddress by mutableStateOf("") private set @@ -107,6 +134,22 @@ class SettingsScreenViewModel @Inject constructor( fun onIpAddressChange(newAddress: String) { ipAddress = newAddress } + + + private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { + val data = body(localConfig.lora) + setConfig(config { lora = data }) + } + + // Set the radio config (also updates our saved copy in preferences) + private fun setConfig(config: Config) { + try { + radioConfigRepository.meshService?.setConfig(config.toByteArray()) + } catch (ex: RemoteException) { + // TODO: Show error message to ui + } + } + } From f4891a18a24784d08a19b7ca6350bb3539505ce9 Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Mon, 30 Dec 2024 18:05:30 -0800 Subject: [PATCH 5/6] refactor: wired up node status text --- .../com/geeksville/mesh/model/BTScanModel.kt | 16 ++++--- .../geeksville/mesh/ui/SettingsFragment.kt | 15 +++--- .../com/geeksville/mesh/ui/SettingsScreen.kt | 48 ++++++++++++++----- .../mesh/ui/SettingsScreenViewModel.kt | 14 ++++-- 4 files changed, 64 insertions(+), 29 deletions(-) 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 f71c1e381..8f5b39cd1 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -24,7 +24,10 @@ import android.content.Context import android.hardware.usb.UsbManager import android.net.nsd.NsdServiceInfo import android.os.RemoteException +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -61,8 +64,10 @@ class BTScanModel @Inject constructor( ) : ViewModel(), Logging { private val context: Context get() = application.applicationContext + // TODO: Change this to only externally expose a read-only map val devices = mutableStateMapOf() - val errorText = MutableLiveData(null) + var errorText by mutableStateOf(null) + private set private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface) @@ -102,7 +107,7 @@ class BTScanModel @Inject constructor( }.launchIn(viewModelScope) serviceRepository.statusMessage - .onEach { errorText.value = it } + .onEach { errorText = it } .launchIn(viewModelScope) debug("BTScanModel created") @@ -152,9 +157,6 @@ class BTScanModel @Inject constructor( debug("BTScanModel cleared") } - fun setErrorText(text: String) { - errorText.value = text - } private var scanJob: Job? = null @@ -234,10 +236,10 @@ class BTScanModel @Inject constructor( if (state != BluetoothDevice.BOND_BONDING) { debug("Bonding completed, state=$state") if (state == BluetoothDevice.BOND_BONDED) { - setErrorText(context.getString(R.string.pairing_completed)) + errorText = context.getString(R.string.pairing_completed) changeDeviceAddress(it.fullAddress) } else { - setErrorText(context.getString(R.string.pairing_failed_try_again)) + errorText = context.getString(R.string.pairing_failed_try_again) } } }.catch { ex -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index d1aae0f8b..3d8fbaecd 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -134,7 +134,8 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { else -> null }?.let { val firmwareString = info?.firmwareString ?: getString(R.string.unknown) - scanModel.setErrorText(getString(it, firmwareString)) + // TODO: Implement in new compose UI +// scanModel.setErrorText(getString(it, firmwareString)) } } @@ -223,11 +224,11 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { updateNodeInfo() } - scanModel.errorText.observe(viewLifecycleOwner) { errMsg -> - if (errMsg != null) { - binding.scanStatusText.text = errMsg - } - } +// scanModel.errorText.observe(viewLifecycleOwner) { errMsg -> +// if (errMsg != null) { +// binding.scanStatusText.text = errMsg +// } +// } var scanDialog: AlertDialog? = null scanModel.scanResult.observe(viewLifecycleOwner) { results -> @@ -422,7 +423,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { binding.warningNotPaired.visibility = View.GONE } else if (bluetoothViewModel.enabled.value == true) { binding.warningNotPaired.visibility = View.VISIBLE - scanModel.setErrorText(getString(R.string.not_paired_yet)) +// scanModel.setErrorText(getString(R.string.not_paired_yet)) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt index def4825bb..4717e64c5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -61,7 +61,11 @@ fun SettingsScreen( onRegionSelected = viewModel::onRegionSelected, ) } - RadioConnectionStatusMessage() + RadioConnectionStatusMessage( + errorMessage = btScanModel.errorText, + selectedAddress = btScanModel.selectedAddress, + connectedRadioFirmwareVersion = viewModel.nodeFirmwareVersion + ) RadioSelectorRadioButtons( devices = btScanModel.devices.values.toList(), onDeviceSelected = { btScanModel.onSelected(it) }, @@ -76,13 +80,15 @@ fun SettingsScreen( ProvideLocationCheckBox(enabled = viewModel.enableProvideLocation) } - Text( - text = stringResource(R.string.warning_not_paired), - style = MaterialTheme.typography.caption, - modifier = Modifier - .padding(top = 16.dp) - .alpha(0.7f) - ) + if (btScanModel.selectedAddress == null || btScanModel.selectedAddress == "m") { + Text( + text = stringResource(R.string.warning_not_paired), + style = MaterialTheme.typography.caption, + modifier = Modifier + .padding(top = 16.dp) + .alpha(0.7f) + ) + } Spacer(modifier = Modifier.weight(1f)) @@ -256,9 +262,29 @@ private fun ProvideLocationCheckBox(modifier: Modifier = Modifier, enabled: Bool } @Composable -private fun RadioConnectionStatusMessage(modifier: Modifier = Modifier) { - // TODO - add condition for if we are paired or not - Text(stringResource(R.string.not_paired_yet), modifier = modifier) +private fun RadioConnectionStatusMessage( + modifier: Modifier = Modifier, + errorMessage: String?, + selectedAddress: String? = null, + connectedRadioFirmwareVersion: String? +) { + when { + selectedAddress.isNullOrBlank() -> { + val message = stringResource(R.string.not_paired_yet) + Text(message, modifier = modifier) + } + connectedRadioFirmwareVersion != null -> { + val message = stringResource(R.string.connected_to, connectedRadioFirmwareVersion) + Text(message, modifier = modifier) + } + errorMessage != null -> { + Text(errorMessage, modifier = modifier) + } + else -> { + val message = stringResource(R.string.not_connected) + Text(message, modifier = modifier) + } + } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt index 95e47f933..e13a2baa8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -72,10 +72,12 @@ class SettingsScreenViewModel @Inject constructor( var isConnected = false private set + var nodeFirmwareVersion by mutableStateOf(null) + init { viewModelScope.launch { radioConfigRepository.connectionState.collect { connectionState -> - isConnected = connectionState == MeshService.ConnectionState.CONNECTED + isConnected = connectionState.isConnected() showNodeSettings = isConnected showProvideLocation = isConnected enableUsernameEdit = isConnected && !isManaged @@ -88,6 +90,12 @@ class SettingsScreenViewModel @Inject constructor( } } + viewModelScope.launch { + nodeRepository.myNodeInfo.collect { node -> + nodeFirmwareVersion = node?.firmwareString + } + } + viewModelScope.launch { radioConfigRepository.localConfigFlow.collect { localConfig = it @@ -101,9 +109,7 @@ class SettingsScreenViewModel @Inject constructor( /** * Pull the latest device info from the model and into the GUI */ - suspend fun updateNodeInfo() { - - + fun updateNodeInfo() { enableProvideLocation = locationRepository.hasGps() // update the region selection from the device From 6a5a68463e9b75ead4c6d1e8fcffce6466924ebe Mon Sep 17 00:00:00 2001 From: Roy Matero Date: Wed, 1 Jan 2025 13:50:45 -0800 Subject: [PATCH 6/6] refactor: change model to uistate -> event and effect model --- app/build.gradle | 3 + .../com/geeksville/mesh/model/BTScanModel.kt | 80 +++++++--- .../geeksville/mesh/ui/SettingsFragment.kt | 20 +-- .../com/geeksville/mesh/ui/SettingsScreen.kt | 105 +++++++++---- .../mesh/ui/SettingsScreenViewModel.kt | 138 ++++++++++++------ 5 files changed, 243 insertions(+), 103 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index fb4ef7513..c80527b53 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -266,6 +266,9 @@ dependencies { // detekt ktlint formatting detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7") + + // Accompanist permissions + implementation "com.google.accompanist:accompanist-permissions:0.34.0" } ksp { 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 8f5b39cd1..32f29aa12 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -23,17 +23,14 @@ import android.bluetooth.BluetoothDevice import android.content.Context import android.hardware.usb.UsbManager import android.net.nsd.NsdServiceInfo +import android.os.Build import android.os.RemoteException -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.model.BTScanModel.DeviceListEntry import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.radio.InterfaceId @@ -45,13 +42,32 @@ import com.geeksville.mesh.util.anonymize import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject +sealed class Effect { + object RequestBluetoothPermission : Effect() + object ShowBluetoothIsDisabled : Effect() + object RequestForCheckLocationPermission : Effect() +} + +const val SCAN_PERIOD: Long = 10000 // Stops scanning after 10 seconds + +data class UIState( + val devices: Map, + val errorText: String?, + val scanning: Boolean, +) + @HiltViewModel class BTScanModel @Inject constructor( private val application: Application, @@ -63,11 +79,13 @@ class BTScanModel @Inject constructor( private val radioInterfaceService: RadioInterfaceService, ) : ViewModel(), Logging { + private val _effect = MutableSharedFlow() + val effect = _effect.asSharedFlow() + + private val _uiState = MutableStateFlow(UIState(emptyMap(), null, false)) + val uiState = _uiState.asStateFlow() + private val context: Context get() = application.applicationContext - // TODO: Change this to only externally expose a read-only map - val devices = mutableStateMapOf() - var errorText by mutableStateOf(null) - private set private val showMockInterface = MutableStateFlow(radioInterfaceService.isMockInterface) @@ -82,8 +100,8 @@ class BTScanModel @Inject constructor( usbRepository.serialDevicesWithDrivers, showMockInterface, ) { ble, tcp, usb, showMockInterface -> - fun addDevice(entry: DeviceListEntry) { - devices.put(entry.fullAddress, entry) + suspend fun addDevice(entry: DeviceListEntry) { + _uiState.emit(uiState.value.copy(devices = uiState.value.devices + (entry.fullAddress to entry))) } // Include a placeholder for "None" @@ -94,7 +112,9 @@ class BTScanModel @Inject constructor( } // Include paired Bluetooth devices - ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach(::addDevice) + ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name }.forEach{ + addDevice(it) + } // Include Network Service Discovery tcp.forEach { service -> @@ -107,7 +127,7 @@ class BTScanModel @Inject constructor( }.launchIn(viewModelScope) serviceRepository.statusMessage - .onEach { errorText = it } + .onEach { _uiState.emit(uiState.value.copy(errorText = it)) } .launchIn(viewModelScope) debug("BTScanModel created") @@ -168,12 +188,12 @@ class BTScanModel @Inject constructor( val scanResult = MutableLiveData>(mutableMapOf()) - fun clearScanResults() { + suspend fun clearScanResults() { stopScan() scanResult.value = mutableMapOf() } - fun stopScan() { + private suspend fun stopScan() { if (scanJob != null) { debug("stopping scan") try { @@ -184,14 +204,14 @@ class BTScanModel @Inject constructor( scanJob = null } } - _spinner.value = false + _uiState.emit(uiState.value.copy(scanning = false)) } @SuppressLint("MissingPermission") - fun startScan() { + private suspend fun startScan() { debug("starting classic scan") - _spinner.value = true + _uiState.emit(uiState.value.copy(scanning = true)) scanJob = bluetoothRepository.scan() .onEach { result -> val fullAddress = radioInterfaceService.toInterfaceAddress( @@ -225,6 +245,24 @@ class BTScanModel @Inject constructor( } } + fun scanForDevices() { + var job: Job? = null + job = viewModelScope.launch { + bluetoothRepository.state.value.let { state -> + if (!state.enabled) { + _effect.emit(Effect.ShowBluetoothIsDisabled) + job?.cancel() + } + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + _effect.emit(Effect.RequestForCheckLocationPermission) + } + startScan() + delay(SCAN_PERIOD) + stopScan() + } + } + @SuppressLint("MissingPermission") private fun requestBonding(it: DeviceListEntry) { val device = bluetoothRepository.getRemoteDevice(it.address) ?: return @@ -236,10 +274,10 @@ class BTScanModel @Inject constructor( if (state != BluetoothDevice.BOND_BONDING) { debug("Bonding completed, state=$state") if (state == BluetoothDevice.BOND_BONDED) { - errorText = context.getString(R.string.pairing_completed) + _uiState.emit(uiState.value.copy(errorText = context.getString(R.string.pairing_completed))) changeDeviceAddress(it.fullAddress) } else { - errorText = context.getString(R.string.pairing_failed_try_again) + _uiState.emit(uiState.value.copy(errorText = context.getString(R.string.pairing_failed_try_again))) } } }.catch { ex -> @@ -283,6 +321,4 @@ class BTScanModel @Inject constructor( } } - private val _spinner = MutableLiveData(false) - val spinner: LiveData get() = _spinner } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 3d8fbaecd..09f4df7b1 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -134,7 +134,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { else -> null }?.let { val firmwareString = info?.firmwareString ?: getString(R.string.unknown) - // TODO: Implement in new compose UI // scanModel.setErrorText(getString(it, firmwareString)) } } @@ -242,23 +241,24 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { ) { dialog, position -> val selectedDevice = devices.elementAt(position) scanModel.onSelected(selectedDevice) - scanModel.clearScanResults() +// scanModel.clearScanResults() dialog.dismiss() scanDialog = null } .setPositiveButton(R.string.cancel) { dialog, _ -> - scanModel.clearScanResults() +// scanModel.clearScanResults() dialog.dismiss() scanDialog = null } .show() } + // TODO: Implement progress indicator in new compose ui // show the spinner when [spinner] is true - scanModel.spinner.observe(viewLifecycleOwner) { show -> - binding.changeRadioButton.isEnabled = !show - binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE - } +// scanModel.spinner.observe(viewLifecycleOwner) { show -> +// binding.changeRadioButton.isEnabled = !show +// binding.scanProgressBar.visibility = if (show) View.VISIBLE else View.GONE +// } binding.usernameEditText.onEditorAction(EditorInfo.IME_ACTION_DONE) { debug("received IME_ACTION_DONE") @@ -436,13 +436,13 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { if (!scanning) { // Stops scanning after a pre-defined scan period. Handler(Looper.getMainLooper()).postDelayed({ scanning = false - scanModel.stopScan() +// scanModel.stopScan() }, SCAN_PERIOD) scanning = true - scanModel.startScan() +// scanModel.startScan() } else { scanning = false - scanModel.stopScan() +// scanModel.stopScan() } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt index 4717e64c5..e471758c3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreen.kt @@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.RadioButton @@ -26,48 +27,86 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.R +import com.geeksville.mesh.android.getBluetoothPermissions import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.RegionInfo import com.geeksville.mesh.ui.theme.AppTheme +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +@OptIn(ExperimentalPermissionsApi::class) @Composable fun SettingsScreen( modifier: Modifier = Modifier, viewModel: SettingsScreenViewModel = hiltViewModel(), btScanModel: BTScanModel = hiltViewModel(), ) { - LaunchedEffect(btScanModel.devices, viewModel.isConnected) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val btScanUiState by btScanModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + LaunchedEffect(btScanUiState.devices, uiState.isConnected) { viewModel.updateNodeInfo() } + val multiplePermissionsState = + rememberMultiplePermissionsState(context.getBluetoothPermissions().toList()) + LaunchedEffect(Unit) { + viewModel.effectFlow.collect { effect -> + when (effect) { + is Effect.CheckForBluetoothPermission -> { + if (multiplePermissionsState.allPermissionsGranted) { + btScanModel.scanForDevices() + } else { + // Show a dialog explaining why we need the permissions + multiplePermissionsState.launchMultiplePermissionRequest() + btScanModel.scanForDevices() + } + } + } + } + + btScanModel.effect.collect { scanEffect -> + when (scanEffect) { + com.geeksville.mesh.model.Effect.RequestForCheckLocationPermission -> TODO() + com.geeksville.mesh.model.Effect.RequestBluetoothPermission -> TODO() + com.geeksville.mesh.model.Effect.ShowBluetoothIsDisabled -> TODO() + else -> {} + } + } + } + Surface { Column(modifier = modifier.padding(16.dp)) { - if (viewModel.showNodeSettings) { + if (uiState.showNodeSettings) { NameAndRegionRow( - textValue = viewModel.userName, + textValue = uiState.userName, onValueChange = viewModel::onUserNameChange, - dropDownExpanded = viewModel.regionDropDownExpanded, + dropDownExpanded = uiState.regionDropDownExpanded, onToggleDropDown = viewModel::onToggleRegionDropDown, - selectedRegion = viewModel.selectedRegion, + selectedRegion = uiState.selectedRegion, onRegionSelected = viewModel::onRegionSelected, ) } RadioConnectionStatusMessage( - errorMessage = btScanModel.errorText, + errorMessage = btScanUiState.errorText, selectedAddress = btScanModel.selectedAddress, connectedRadioFirmwareVersion = viewModel.nodeFirmwareVersion ) RadioSelectorRadioButtons( - devices = btScanModel.devices.values.toList(), + devices = btScanUiState.devices.values.toList(), onDeviceSelected = { btScanModel.onSelected(it) }, selectedAddress = btScanModel.selectedNotNull, @@ -76,8 +115,8 @@ fun SettingsScreen( ipAddress = viewModel.ipAddress, onIpAddressChange = viewModel::onIpAddressChange ) - if (viewModel.showProvideLocation) { - ProvideLocationCheckBox(enabled = viewModel.enableProvideLocation) + if (uiState.showProvideLocation) { + ProvideLocationCheckBox(enabled = uiState.enableProvideLocation) } if (btScanModel.selectedAddress == null || btScanModel.selectedAddress == "m") { @@ -92,27 +131,15 @@ fun SettingsScreen( Spacer(modifier = Modifier.weight(1f)) - ExtendedFloatingActionButton( - onClick = { /*TODO*/ }, - icon = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_radio), - tint = Color.White - ) - }, - text = { - Text( - stringResource(R.string.add_radio), - color = Color.White - ) - }, - modifier = Modifier.fillMaxWidth() + AddRadioFloatingActionButton( + onClick = viewModel::onAddRadioButtonClicked, + showScanningProgress = btScanUiState.scanning ) } } } + @Composable private fun NameAndRegionRow( modifier: Modifier = Modifier, @@ -273,13 +300,16 @@ private fun RadioConnectionStatusMessage( val message = stringResource(R.string.not_paired_yet) Text(message, modifier = modifier) } + connectedRadioFirmwareVersion != null -> { val message = stringResource(R.string.connected_to, connectedRadioFirmwareVersion) Text(message, modifier = modifier) } + errorMessage != null -> { Text(errorMessage, modifier = modifier) } + else -> { val message = stringResource(R.string.not_connected) Text(message, modifier = modifier) @@ -287,6 +317,29 @@ private fun RadioConnectionStatusMessage( } } +@Composable +fun AddRadioFloatingActionButton( + modifier: Modifier = Modifier, + showScanningProgress: Boolean = false, + onClick: () -> Unit = {}, +) { + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + if (showScanningProgress) { + CircularProgressIndicator(modifier = Modifier) + } else { + FloatingActionButton( + onClick = onClick, + modifier = Modifier + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_radio), + tint = Color.White + ) + } + } + } +} @Preview @Composable diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt index e13a2baa8..1f902f259 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsScreenViewModel.kt @@ -17,9 +17,33 @@ import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.service.MeshService import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject +sealed class Effect { + object CheckForBluetoothPermission : Effect() +} + +data class UIState( + val userName: String, + val regionDropDownExpanded: Boolean, + val selectedRegion: RegionInfo, + val localConfig: LocalConfig, + val showNodeSettings: Boolean, + val showProvideLocation: Boolean, + val enableUsernameEdit: Boolean, + val enableProvideLocation: Boolean, + val errorText: String?, + val isConnected: Boolean, + val nodeFirmwareVersion: String?, + val ipAddress: String, +) + @HiltViewModel class SettingsScreenViewModel @Inject constructor( private val application: Application, @@ -28,65 +52,77 @@ class SettingsScreenViewModel @Inject constructor( private val nodeRepository: NodeRepository, ) : ViewModel() { - var userName by mutableStateOf("") - private set + private val _effectFlow = MutableSharedFlow(replay = 0) + val effectFlow: SharedFlow = _effectFlow.asSharedFlow() + + private val _uiState = MutableStateFlow( + UIState( + userName = "", + regionDropDownExpanded = false, + selectedRegion = RegionInfo.UNSET, + localConfig = LocalConfig.getDefaultInstance(), + showNodeSettings = false, + showProvideLocation = false, + enableUsernameEdit = false, + enableProvideLocation = false, + errorText = null, + isConnected = false, + nodeFirmwareVersion = null, + ipAddress = "", + + ) + ) + val uiState: StateFlow = _uiState fun onUserNameChange(newName: String) { - userName = newName + _uiState.value = _uiState.value.copy(userName = newName) } - var regionDropDownExpanded by mutableStateOf(false) - private set fun onToggleRegionDropDown() { - regionDropDownExpanded = !regionDropDownExpanded + viewModelScope.launch { + _uiState.emit( + _uiState.value.copy(regionDropDownExpanded = !_uiState.value.regionDropDownExpanded) + ) + } } - var selectedRegion by mutableStateOf(RegionInfo.UNSET) - private set fun onRegionSelected(newRegion: RegionInfo) { - selectedRegion = newRegion + viewModelScope.launch { + _uiState.emit(_uiState.value.copy(selectedRegion = newRegion)) + } onToggleRegionDropDown() updateLoraConfig { it.toBuilder().setRegion(newRegion.regionCode).build() } } - var localConfig by mutableStateOf(LocalConfig.getDefaultInstance()) - private set - var showNodeSettings by mutableStateOf(false) - private set - var showProvideLocation by mutableStateOf(false) - private set - var enableUsernameEdit by mutableStateOf(false) - private set - var enableProvideLocation by mutableStateOf(false) - private set - - // managed mode disables all access to configuration - val isManaged: Boolean get() = localConfig.device.isManaged || localConfig.security.isManaged - - var errorText by mutableStateOf(null as String?) - private set - - var isConnected = false - private set - var nodeFirmwareVersion by mutableStateOf(null) init { viewModelScope.launch { radioConfigRepository.connectionState.collect { connectionState -> - isConnected = connectionState.isConnected() - showNodeSettings = isConnected - showProvideLocation = isConnected - enableUsernameEdit = isConnected && !isManaged + // managed mode disables all access to configuration + val isManaged = + _uiState.value.localConfig.let { it.device.isManaged || it.security.isManaged } + _uiState.emit( + _uiState.value.copy( + isConnected = connectionState.isConnected(), + showNodeSettings = connectionState.isConnected(), + showProvideLocation = connectionState.isConnected(), + enableUsernameEdit = connectionState.isConnected() && !isManaged + ) + ) } } viewModelScope.launch { nodeRepository.ourNodeInfo.collect { node -> - userName = node?.user?.longName ?: userName + _uiState.emit( + _uiState.value.copy( + userName = node?.user?.longName ?: _uiState.value.userName + ) + ) } } @@ -98,10 +134,13 @@ class SettingsScreenViewModel @Inject constructor( viewModelScope.launch { radioConfigRepository.localConfigFlow.collect { - localConfig = it - selectedRegion = localConfig.lora.region.let { region -> - RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET - } + _uiState.emit(_uiState.value.copy( + localConfig = it, + selectedRegion = uiState.value.localConfig.lora.region.let { region -> + RegionInfo.entries.firstOrNull { it.regionCode == region } + ?: RegionInfo.UNSET + } + )) } } } @@ -109,14 +148,15 @@ class SettingsScreenViewModel @Inject constructor( /** * Pull the latest device info from the model and into the GUI */ - fun updateNodeInfo() { - enableProvideLocation = locationRepository.hasGps() - + suspend fun updateNodeInfo() { // update the region selection from the device - val region = localConfig.lora.region + val region = uiState.value.localConfig.lora.region - selectedRegion = + _uiState.emit(_uiState.value.copy( + enableProvideLocation = locationRepository.hasGps(), + selectedRegion = RegionInfo.entries.firstOrNull { it.regionCode == region } ?: RegionInfo.UNSET + )) // Update the status string (highest priority messages first) val regionUnset = region == Config.LoRaConfig.RegionCode.UNSET @@ -129,7 +169,9 @@ class SettingsScreenViewModel @Inject constructor( MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping else -> null }?.let { - errorText = info?.firmwareString ?: application.resources.getString(R.string.unknown) + val errorText = + info?.firmwareString ?: application.resources.getString(R.string.unknown) + _uiState.emit(_uiState.value.copy(errorText = errorText)) } } @@ -143,7 +185,7 @@ class SettingsScreenViewModel @Inject constructor( private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { - val data = body(localConfig.lora) + val data = body(uiState.value.localConfig.lora) setConfig(config { lora = data }) } @@ -156,6 +198,12 @@ class SettingsScreenViewModel @Inject constructor( } } + fun onAddRadioButtonClicked() { + viewModelScope.launch { + _effectFlow.emit(Effect.CheckForBluetoothPermission) + } + } + }