package com.geeksville.mesh.ui import android.app.Activity import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.AlertDialog import androidx.compose.material.Button import androidx.compose.material.Card import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowRight import androidx.compose.material.icons.twotone.Warning import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.geeksville.mesh.Position import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.config import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.RadioConfigViewModel import com.geeksville.mesh.moduleConfig import com.geeksville.mesh.service.MeshService.ConnectionState import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList import com.geeksville.mesh.ui.components.config.AudioConfigItemList import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList import com.geeksville.mesh.ui.components.config.CannedMessageConfigItemList import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList import com.geeksville.mesh.ui.components.config.DetectionSensorConfigItemList import com.geeksville.mesh.ui.components.config.DeviceConfigItemList import com.geeksville.mesh.ui.components.config.DisplayConfigItemList import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog import com.geeksville.mesh.ui.components.config.ExternalNotificationConfigItemList import com.geeksville.mesh.ui.components.config.LoRaConfigItemList import com.geeksville.mesh.ui.components.config.MQTTConfigItemList import com.geeksville.mesh.ui.components.config.NeighborInfoConfigItemList import com.geeksville.mesh.ui.components.config.NetworkConfigItemList import com.geeksville.mesh.ui.components.config.PacketResponseStateDialog import com.geeksville.mesh.ui.components.config.PaxcounterConfigItemList import com.geeksville.mesh.ui.components.config.PositionConfigItemList import com.geeksville.mesh.ui.components.config.PowerConfigItemList import com.geeksville.mesh.ui.components.config.RangeTestConfigItemList import com.geeksville.mesh.ui.components.config.RemoteHardwareConfigItemList import com.geeksville.mesh.ui.components.config.SecurityConfigItemList import com.geeksville.mesh.ui.components.config.SerialConfigItemList import com.geeksville.mesh.ui.components.config.StoreForwardConfigItemList import com.geeksville.mesh.ui.components.config.TelemetryConfigItemList import com.geeksville.mesh.ui.components.config.UserConfigItemList import com.google.accompanist.themeadapter.appcompat.AppCompatTheme import dagger.hilt.android.AndroidEntryPoint internal fun FragmentManager.navigateToRadioConfig(destNum: Int? = null) { val radioConfigFragment = DeviceSettingsFragment().apply { arguments = bundleOf("destNum" to destNum) } beginTransaction() .replace(R.id.mainActivityLayout, radioConfigFragment) .addToBackStack(null) .commit() } @AndroidEntryPoint class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging { private val model: RadioConfigViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val destNum = arguments?.getInt("destNum") model.setDestNum(destNum) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) setContent { val node by model.destNode.collectAsStateWithLifecycle() AppCompatTheme { val navController: NavHostController = rememberNavController() // Get current back stack entry // val backStackEntry by navController.currentBackStackEntryAsState() // Get the name of the current screen // val currentScreen = backStackEntry?.destination?.route?.let { route -> // NavRoute.entries.find { it.name == route }?.title // } Scaffold( topBar = { MeshAppBar( currentScreen = node?.user?.longName ?: stringResource(R.string.unknown_username), // canNavigateBack = navController.previousBackStackEntry != null, // navigateUp = { navController.navigateUp() }, canNavigateBack = true, navigateUp = { if (navController.previousBackStackEntry != null) { navController.navigateUp() } else { parentFragmentManager.popBackStack() } }, ) } ) { innerPadding -> RadioConfigNavHost( node = node, viewModel = model, navController = navController, modifier = Modifier.padding(innerPadding), ) } } } } } } enum class AdminRoute(@StringRes val title: Int) { REBOOT(R.string.reboot), SHUTDOWN(R.string.shutdown), FACTORY_RESET(R.string.factory_reset), NODEDB_RESET(R.string.nodedb_reset), } // Config (configType = AdminProtos.AdminMessage.ConfigType) enum class ConfigRoute(val title: String, val configType: Int = 0) { USER("User"), CHANNELS("Channels"), DEVICE("Device", 0), POSITION("Position", 1), POWER("Power", 2), NETWORK("Network", 3), DISPLAY("Display", 4), LORA("LoRa", 5), BLUETOOTH("Bluetooth", 6), SECURITY("Security", configType = 7), } // ModuleConfig (configType = AdminProtos.AdminMessage.ModuleConfigType) enum class ModuleRoute(val title: String, val configType: Int = 0) { MQTT("MQTT", 0), SERIAL("Serial", 1), EXTERNAL_NOTIFICATION("External Notification", 2), STORE_FORWARD("Store & Forward", 3), RANGE_TEST("Range Test", 4), TELEMETRY("Telemetry", 5), CANNED_MESSAGE("Canned Message", 6), AUDIO("Audio", 7), REMOTE_HARDWARE("Remote Hardware", 8), NEIGHBOR_INFO("Neighbor Info", 9), AMBIENT_LIGHTING("Ambient Lighting", 10), DETECTION_SENSOR("Detection Sensor", 11), PAXCOUNTER("Paxcounter", 12), } /** * Generic sealed class defines each possible state of a response. */ sealed class ResponseState { data object Empty : ResponseState() data class Loading(var total: Int = 1, var completed: Int = 0) : ResponseState() data class Success(val result: T) : ResponseState() data class Error(val error: String) : ResponseState() fun isWaiting() = this !is Empty } @Composable private fun MeshAppBar( currentScreen: String, canNavigateBack: Boolean, navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( title = { Text(currentScreen) }, modifier = modifier, navigationIcon = { if (canNavigateBack) { IconButton(onClick = navigateUp) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, null, ) } } } ) } @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun RadioConfigNavHost( node: NodeEntity?, viewModel: RadioConfigViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), modifier: Modifier, ) { val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val connected = connectionState == ConnectionState.CONNECTED && node != null val destNum = node?.num ?: 0 val isLocal = destNum == viewModel.myNodeNum val radioConfigState by viewModel.radioConfigState.collectAsStateWithLifecycle() val deviceProfile by viewModel.deviceProfile.collectAsStateWithLifecycle() val isWaiting = radioConfigState.responseState.isWaiting() var showEditDeviceProfileDialog by remember { mutableStateOf(false) } val importConfigLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) { showEditDeviceProfileDialog = true it.data?.data?.let { uri -> viewModel.importProfile(uri) } } } val exportConfigLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { if (it.resultCode == Activity.RESULT_OK) { it.data?.data?.let { uri -> viewModel.exportProfile(uri) } } } if (showEditDeviceProfileDialog) EditDeviceProfileDialog( title = if (deviceProfile != null) "Import configuration" else "Export configuration", deviceProfile = deviceProfile ?: viewModel.currentDeviceProfile, onConfirm = { showEditDeviceProfileDialog = false if (deviceProfile != null) { viewModel.installProfile(it) } else { viewModel.setDeviceProfile(it) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/*" putExtra(Intent.EXTRA_TITLE, "${destNum.toUInt()}.cfg") } exportConfigLauncher.launch(intent) } }, onDismiss = { showEditDeviceProfileDialog = false viewModel.setDeviceProfile(null) } ) if (isWaiting) PacketResponseStateDialog( radioConfigState.responseState, onDismiss = { showEditDeviceProfileDialog = false viewModel.clearPacketResponse() }, onComplete = { val route = radioConfigState.route if (ConfigRoute.entries.any { it.name == route } || ModuleRoute.entries.any { it.name == route }) { navController.navigate(route) viewModel.clearPacketResponse() } } ) NavHost( navController = navController, startDestination = "home", modifier = modifier, ) { composable("home") { RadioConfigItemList( enabled = connected && !isWaiting, isLocal = isLocal, onRouteClick = { route -> viewModel.setResponseStateLoading(route) }, onImport = { viewModel.clearPacketResponse() viewModel.setDeviceProfile(null) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/*" } importConfigLauncher.launch(intent) }, onExport = { viewModel.clearPacketResponse() viewModel.setDeviceProfile(null) showEditDeviceProfileDialog = true }, ) } composable(ConfigRoute.USER.name) { UserConfigItemList( userConfig = radioConfigState.userConfig, enabled = connected, onSaveClicked = { userInput -> viewModel.setRemoteOwner(destNum, userInput) } ) } composable(ConfigRoute.CHANNELS.name) { ChannelSettingsItemList( settingsList = radioConfigState.channelList, modemPresetName = Channel(loraConfig = radioConfigState.radioConfig.lora).name, enabled = connected, maxChannels = viewModel.maxChannels, onPositiveClicked = { channelListInput -> viewModel.updateChannels(destNum, channelListInput, radioConfigState.channelList) }, ) } composable(ConfigRoute.DEVICE.name) { DeviceConfigItemList( deviceConfig = radioConfigState.radioConfig.device, enabled = connected, onSaveClicked = { deviceInput -> val config = config { device = deviceInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.POSITION.name) { val currentPosition = Position( latitude = node?.latitude ?: 0.0, longitude = node?.longitude ?: 0.0, altitude = node?.position?.altitude ?: 0, time = 1, // ignore time for fixed_position ) PositionConfigItemList( location = currentPosition, positionConfig = radioConfigState.radioConfig.position, enabled = connected, onSaveClicked = { locationInput, positionInput -> if (positionInput.fixedPosition) { if (locationInput != currentPosition) { viewModel.setFixedPosition(destNum, locationInput) } } else { if (radioConfigState.radioConfig.position.fixedPosition) { // fixed position changed from enabled to disabled viewModel.removeFixedPosition(destNum) } } val config = config { position = positionInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.POWER.name) { PowerConfigItemList( powerConfig = radioConfigState.radioConfig.power, enabled = connected, onSaveClicked = { powerInput -> val config = config { power = powerInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.NETWORK.name) { NetworkConfigItemList( networkConfig = radioConfigState.radioConfig.network, enabled = connected, onSaveClicked = { networkInput -> val config = config { network = networkInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.DISPLAY.name) { DisplayConfigItemList( displayConfig = radioConfigState.radioConfig.display, enabled = connected, onSaveClicked = { displayInput -> val config = config { display = displayInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.LORA.name) { LoRaConfigItemList( loraConfig = radioConfigState.radioConfig.lora, primarySettings = radioConfigState.channelList.getOrNull(0) ?: return@composable, enabled = connected, onSaveClicked = { loraInput -> val config = config { lora = loraInput } viewModel.setRemoteConfig(destNum, config) }, hasPaFan = viewModel.hasPaFan, ) } composable(ConfigRoute.BLUETOOTH.name) { BluetoothConfigItemList( bluetoothConfig = radioConfigState.radioConfig.bluetooth, enabled = connected, onSaveClicked = { bluetoothInput -> val config = config { bluetooth = bluetoothInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ConfigRoute.SECURITY.name) { SecurityConfigItemList( securityConfig = radioConfigState.radioConfig.security, enabled = connected, onConfirm = { securityInput -> val config = config { security = securityInput } viewModel.setRemoteConfig(destNum, config) } ) } composable(ModuleRoute.MQTT.name) { MQTTConfigItemList( mqttConfig = radioConfigState.moduleConfig.mqtt, enabled = connected, onSaveClicked = { mqttInput -> val config = moduleConfig { mqtt = mqttInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.SERIAL.name) { SerialConfigItemList( serialConfig = radioConfigState.moduleConfig.serial, enabled = connected, onSaveClicked = { serialInput -> val config = moduleConfig { serial = serialInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.EXTERNAL_NOTIFICATION.name) { ExternalNotificationConfigItemList( ringtone = radioConfigState.ringtone, extNotificationConfig = radioConfigState.moduleConfig.externalNotification, enabled = connected, onSaveClicked = { ringtoneInput, extNotificationInput -> if (ringtoneInput != radioConfigState.ringtone) { viewModel.setRingtone(destNum, ringtoneInput) } if (extNotificationInput != radioConfigState.moduleConfig.externalNotification) { val config = moduleConfig { externalNotification = extNotificationInput } viewModel.setModuleConfig(destNum, config) } } ) } composable(ModuleRoute.STORE_FORWARD.name) { StoreForwardConfigItemList( storeForwardConfig = radioConfigState.moduleConfig.storeForward, enabled = connected, onSaveClicked = { storeForwardInput -> val config = moduleConfig { storeForward = storeForwardInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.RANGE_TEST.name) { RangeTestConfigItemList( rangeTestConfig = radioConfigState.moduleConfig.rangeTest, enabled = connected, onSaveClicked = { rangeTestInput -> val config = moduleConfig { rangeTest = rangeTestInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.TELEMETRY.name) { TelemetryConfigItemList( telemetryConfig = radioConfigState.moduleConfig.telemetry, enabled = connected, onSaveClicked = { telemetryInput -> val config = moduleConfig { telemetry = telemetryInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.CANNED_MESSAGE.name) { CannedMessageConfigItemList( messages = radioConfigState.cannedMessageMessages, cannedMessageConfig = radioConfigState.moduleConfig.cannedMessage, enabled = connected, onSaveClicked = { messagesInput, cannedMessageInput -> if (messagesInput != radioConfigState.cannedMessageMessages) { viewModel.setCannedMessages(destNum, messagesInput) } if (cannedMessageInput != radioConfigState.moduleConfig.cannedMessage) { val config = moduleConfig { cannedMessage = cannedMessageInput } viewModel.setModuleConfig(destNum, config) } } ) } composable(ModuleRoute.AUDIO.name) { AudioConfigItemList( audioConfig = radioConfigState.moduleConfig.audio, enabled = connected, onSaveClicked = { audioInput -> val config = moduleConfig { audio = audioInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.REMOTE_HARDWARE.name) { RemoteHardwareConfigItemList( remoteHardwareConfig = radioConfigState.moduleConfig.remoteHardware, enabled = connected, onSaveClicked = { remoteHardwareInput -> val config = moduleConfig { remoteHardware = remoteHardwareInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.NEIGHBOR_INFO.name) { NeighborInfoConfigItemList( neighborInfoConfig = radioConfigState.moduleConfig.neighborInfo, enabled = connected, onSaveClicked = { neighborInfoInput -> val config = moduleConfig { neighborInfo = neighborInfoInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.AMBIENT_LIGHTING.name) { AmbientLightingConfigItemList( ambientLightingConfig = radioConfigState.moduleConfig.ambientLighting, enabled = connected, onSaveClicked = { ambientLightingInput -> val config = moduleConfig { ambientLighting = ambientLightingInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.DETECTION_SENSOR.name) { DetectionSensorConfigItemList( detectionSensorConfig = radioConfigState.moduleConfig.detectionSensor, enabled = connected, onSaveClicked = { detectionSensorInput -> val config = moduleConfig { detectionSensor = detectionSensorInput } viewModel.setModuleConfig(destNum, config) } ) } composable(ModuleRoute.PAXCOUNTER.name) { PaxcounterConfigItemList( paxcounterConfig = radioConfigState.moduleConfig.paxcounter, enabled = connected, onSaveClicked = { paxcounterConfigInput -> val config = moduleConfig { paxcounter = paxcounterConfigInput } viewModel.setModuleConfig(destNum, config) } ) } } } @Composable private fun NavCard( title: String, enabled: Boolean, onClick: () -> Unit ) { val color = if (enabled) { MaterialTheme.colors.onSurface } else { MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) } Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 2.dp) .clickable(enabled = enabled) { onClick() }, elevation = 4.dp ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp) ) { Text( text = title, style = MaterialTheme.typography.body1, color = color, modifier = Modifier.weight(1f) ) Icon( Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon", modifier = Modifier.wrapContentSize(), tint = color, ) } } } @Composable private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) { var showDialog by remember { mutableStateOf(false) } if (showDialog) AlertDialog( onDismissRequest = {}, shape = RoundedCornerShape(16.dp), backgroundColor = MaterialTheme.colors.background, title = { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { Icon( imageVector = Icons.TwoTone.Warning, contentDescription = "warning", modifier = Modifier.padding(end = 8.dp) ) Text( text = "${stringResource(title)}?\n") Icon( imageVector = Icons.TwoTone.Warning, contentDescription = "warning", modifier = Modifier.padding(start = 8.dp) ) } }, buttons = { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { TextButton( modifier = Modifier.weight(1f), onClick = { showDialog = false }, ) { Text(stringResource(R.string.cancel)) } Button( modifier = Modifier.weight(1f), onClick = { showDialog = false onClick() }, ) { Text(stringResource(R.string.send)) } } } ) Column { Spacer(modifier = Modifier.height(4.dp)) Button( modifier = Modifier .fillMaxWidth() .height(48.dp), enabled = enabled, onClick = { showDialog = true }, ) { Text(text = stringResource(title)) } } } @Composable private fun RadioConfigItemList( enabled: Boolean = true, isLocal: Boolean = true, modifier: Modifier = Modifier, onRouteClick: (Enum<*>) -> Unit = {}, onImport: () -> Unit = {}, onExport: () -> Unit = {}, ) { LazyColumn( modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp), ) { item { PreferenceCategory(stringResource(R.string.device_settings)) } items(ConfigRoute.entries) { NavCard(it.title, enabled = enabled) { onRouteClick(it) } } item { PreferenceCategory(stringResource(R.string.module_settings)) } items(ModuleRoute.entries) { NavCard(it.title, enabled = enabled) { onRouteClick(it) } } if (isLocal) { item { PreferenceCategory("Import / Export") } item { NavCard("Import configuration", enabled = enabled) { onImport() } } item { NavCard("Export configuration", enabled = enabled) { onExport() } } } items(AdminRoute.entries) { NavButton(it.title, enabled) { onRouteClick(it) } } } } @Preview(showBackground = true) @Composable private fun RadioSettingsScreenPreview() { RadioConfigItemList() }