meshtastic-android/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt

704 wiersze
30 KiB
Kotlin

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.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.material.Card
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.KeyboardArrowRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateListOf
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.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalFocusManager
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.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AdminProtos.AdminMessage.ConfigType
import com.geeksville.mesh.AdminProtos.AdminMessage.ModuleConfigType
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.NodeInfo
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.channel
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.config
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.components.PreferenceCategory
import com.geeksville.mesh.ui.components.TextDividerPreference
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.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.NetworkConfigItemList
import com.geeksville.mesh.ui.components.config.PacketResponseStateDialog
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.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
@AndroidEntryPoint
class DeviceSettingsFragment(val node: NodeInfo) : ScreenFragment("Radio Configuration"), Logging {
private val model: UIViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
setContent {
AppCompatTheme {
RadioConfigNavHost(node, model)
}
}
}
}
}
enum class ConfigDest(val title: String, val route: String, val config: ConfigType) {
DEVICE("Device", "device", ConfigType.DEVICE_CONFIG),
POSITION("Position", "position", ConfigType.POSITION_CONFIG),
POWER("Power", "power", ConfigType.POWER_CONFIG),
NETWORK("Network", "network", ConfigType.NETWORK_CONFIG),
DISPLAY("Display", "display", ConfigType.DISPLAY_CONFIG),
LORA("LoRa", "lora", ConfigType.LORA_CONFIG),
BLUETOOTH("Bluetooth", "bluetooth", ConfigType.BLUETOOTH_CONFIG);
}
enum class ModuleDest(val title: String, val route: String, val config: ModuleConfigType) {
MQTT("MQTT", "mqtt", ModuleConfigType.MQTT_CONFIG),
SERIAL("Serial", "serial", ModuleConfigType.SERIAL_CONFIG),
EXTERNAL_NOTIFICATION("External Notification", "ext_not", ModuleConfigType.EXTNOTIF_CONFIG),
STORE_FORWARD("Store & Forward", "store_forward", ModuleConfigType.STOREFORWARD_CONFIG),
RANGE_TEST("Range Test", "range_test", ModuleConfigType.RANGETEST_CONFIG),
TELEMETRY("Telemetry", "telemetry", ModuleConfigType.TELEMETRY_CONFIG),
CANNED_MESSAGE("Canned Message", "canned_message", ModuleConfigType.CANNEDMSG_CONFIG),
AUDIO("Audio", "audio", ModuleConfigType.AUDIO_CONFIG),
REMOTE_HARDWARE("Remote Hardware", "remote_hardware", ModuleConfigType.REMOTEHARDWARE_CONFIG);
}
/**
* This sealed class defines each possible state of a packet response.
*/
sealed class PacketResponseState {
object Loading : PacketResponseState() {
var total: Int = 0
var completed: Int = 0
}
data class Success(val packets: List<String>) : PacketResponseState()
object Empty : PacketResponseState()
data class Error(val error: String) : PacketResponseState()
}
@Composable
fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
val navController = rememberNavController()
val focusManager = LocalFocusManager.current
val connectionState by viewModel.connectionState.observeAsState()
val connected = connectionState == MeshService.ConnectionState.CONNECTED
val destNum = node.num
val isLocal = destNum == viewModel.myNodeNum
val maxChannels = viewModel.myNodeInfo.value?.maxChannels ?: 8
var userConfig by remember { mutableStateOf(MeshProtos.User.getDefaultInstance()) }
val channelList = remember { mutableStateListOf<ChannelProtos.ChannelSettings>() }
var radioConfig by remember { mutableStateOf(Config.getDefaultInstance()) }
var moduleConfig by remember { mutableStateOf(ModuleConfig.getDefaultInstance()) }
var location by remember(node) { mutableStateOf(node.position) }
var ringtone by remember { mutableStateOf("") }
var cannedMessageMessages by remember { mutableStateOf("") }
val configResponse by viewModel.packetResponse.collectAsStateWithLifecycle()
val deviceProfile by viewModel.deviceProfile.collectAsStateWithLifecycle()
var packetResponseState by remember { mutableStateOf<PacketResponseState>(PacketResponseState.Empty) }
val isWaiting = packetResponseState is PacketResponseState.Loading
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
val importConfigLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true
it.data?.data?.let { file_uri -> viewModel.importProfile(file_uri) }
}
}
val exportConfigLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { file_uri -> viewModel.exportProfile(file_uri) }
}
}
if (showEditDeviceProfileDialog) EditDeviceProfileDialog(
title = if (deviceProfile != null) "Import configuration" else "Export configuration",
deviceProfile = deviceProfile ?: with(viewModel) {
deviceProfile {
ourNodeInfo.value?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.value.getChannelUrl().toString()
config = localConfig.value
this.moduleConfig = module
}
},
onAddClick = {
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)
}
},
onDismissRequest = {
showEditDeviceProfileDialog = false
viewModel.setDeviceProfile(null)
}
)
if (isWaiting || packetResponseState is PacketResponseState.Error) PacketResponseStateDialog(
packetResponseState,
onDismiss = {
packetResponseState = PacketResponseState.Empty
viewModel.clearPacketResponse()
}
)
if (isWaiting) LaunchedEffect(configResponse) {
val data = configResponse?.meshPacket?.decoded
if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) {
val parsed = MeshProtos.Routing.parseFrom(data.payload)
if (parsed.errorReason != MeshProtos.Routing.Error.NONE)
packetResponseState = PacketResponseState.Error(parsed.errorReason.toString())
}
if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) {
viewModel.clearPacketResponse()
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
when (parsed.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
val response = parsed.getChannelResponse
if (response.index + 1 < maxChannels) {
// Stop once we get to the first disabled entry
if (response.role != ChannelProtos.Channel.Role.DISABLED) {
// Not done yet, request next channel
(packetResponseState as PacketResponseState.Loading).completed++
channelList.add(response.index, response.settings)
viewModel.getChannel(destNum, response.index + 1)
} else {
// Received the last channel, start channel editor
packetResponseState = PacketResponseState.Success(emptyList())
navController.navigate("channels")
}
} else {
// Received max channels, start channel editor
packetResponseState = PacketResponseState.Success(emptyList())
navController.navigate("channels")
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> {
packetResponseState = PacketResponseState.Success(emptyList())
userConfig = parsed.getOwnerResponse
navController.navigate("user")
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
packetResponseState = PacketResponseState.Success(emptyList())
val response = parsed.getConfigResponse
radioConfig = response
enumValues<ConfigDest>().find { it.name == "${response.payloadVariantCase}" }
?.let { navController.navigate(it.route) }
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> {
packetResponseState = PacketResponseState.Success(emptyList())
val response = parsed.getModuleConfigResponse
moduleConfig = response
enumValues<ModuleDest>().find { it.name == "${response.payloadVariantCase}" }
?.let { navController.navigate(it.route) }
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> {
cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse
(packetResponseState as PacketResponseState.Loading).completed++
viewModel.getModuleConfig(destNum, ModuleConfigType.CANNEDMSG_CONFIG_VALUE)
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> {
ringtone = parsed.getRingtoneResponse
(packetResponseState as PacketResponseState.Loading).completed++
viewModel.getModuleConfig(destNum, ModuleConfigType.EXTNOTIF_CONFIG_VALUE)
}
else -> TODO()
}
}
}
NavHost(navController = navController, startDestination = "home") {
composable("home") {
RadioSettingsScreen(
enabled = connected && !isWaiting,
isLocal = isLocal,
headerText = node.user?.longName ?: stringResource(R.string.unknown_username),
onRouteClick = { configType ->
packetResponseState = PacketResponseState.Loading.apply {
total = 1
completed = 0
}
// clearAllConfigs() ?
when (configType) {
"USER" -> { viewModel.getOwner(destNum) }
"CHANNELS" -> {
(packetResponseState as PacketResponseState.Loading).total = maxChannels
channelList.clear()
viewModel.getChannel(destNum, 0)
}
"IMPORT" -> {
packetResponseState = PacketResponseState.Empty
viewModel.setDeviceProfile(null)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
}
importConfigLauncher.launch(intent)
}
"EXPORT" -> {
packetResponseState = PacketResponseState.Empty
showEditDeviceProfileDialog = true
}
is ConfigType -> {
viewModel.getConfig(destNum, configType.number)
}
ModuleConfigType.CANNEDMSG_CONFIG -> {
(packetResponseState as PacketResponseState.Loading).total = 2
viewModel.getCannedMessages(destNum)
}
ModuleConfigType.EXTNOTIF_CONFIG -> {
(packetResponseState as PacketResponseState.Loading).total = 2
viewModel.getRingtone(destNum)
}
is ModuleConfigType -> {
viewModel.getModuleConfig(destNum, configType.number)
}
}
},
)
}
composable("channels") {
ChannelSettingsItemList(
settingsList = channelList,
enabled = connected,
maxChannels = maxChannels,
focusManager = focusManager,
onSaveClicked = { channelListInput ->
focusManager.clearFocus()
(0 until channelList.size.coerceAtLeast(channelListInput.size)).map { i ->
channel {
role = when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1 until channelListInput.size -> ChannelProtos.Channel.Role.SECONDARY
else -> ChannelProtos.Channel.Role.DISABLED
}
index = i
settings = channelListInput.getOrNull(i) ?: channelSettings { }
}
}.forEach { newChannel ->
if (newChannel.settings != channelList.getOrNull(newChannel.index))
viewModel.setRemoteChannel(destNum, newChannel)
}
channelList.clear()
channelList.addAll(channelListInput)
}
)
}
composable("user") {
UserConfigItemList(
userConfig = userConfig,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { userInput ->
focusManager.clearFocus()
viewModel.setRemoteOwner(destNum, userInput)
userConfig = userInput
}
)
}
composable("device") {
DeviceConfigItemList(
deviceConfig = radioConfig.device,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { deviceInput ->
focusManager.clearFocus()
val config = config { device = deviceInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("position") {
PositionConfigItemList(
isLocal = isLocal,
location = location,
positionConfig = radioConfig.position,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { locationInput, positionInput ->
focusManager.clearFocus()
if (locationInput != node.position && positionInput.fixedPosition) {
locationInput?.let { viewModel.requestPosition(destNum, it) }
location = locationInput
}
if (positionInput != radioConfig.position) {
val config = config { position = positionInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
}
)
}
composable("power") {
PowerConfigItemList(
powerConfig = radioConfig.power,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { powerInput ->
focusManager.clearFocus()
val config = config { power = powerInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("network") {
NetworkConfigItemList(
networkConfig = radioConfig.network,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { networkInput ->
focusManager.clearFocus()
val config = config { network = networkInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("display") {
DisplayConfigItemList(
displayConfig = radioConfig.display,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { displayInput ->
focusManager.clearFocus()
val config = config { display = displayInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("lora") {
LoRaConfigItemList(
loraConfig = radioConfig.lora,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { loraInput ->
focusManager.clearFocus()
val config = config { lora = loraInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("bluetooth") {
BluetoothConfigItemList(
bluetoothConfig = radioConfig.bluetooth,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { bluetoothInput ->
focusManager.clearFocus()
val config = config { bluetooth = bluetoothInput }
viewModel.setRemoteConfig(destNum, config)
radioConfig = config
}
)
}
composable("mqtt") {
MQTTConfigItemList(
mqttConfig = moduleConfig.mqtt,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { mqttInput ->
focusManager.clearFocus()
val config = moduleConfig { mqtt = mqttInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("serial") {
SerialConfigItemList(
serialConfig = moduleConfig.serial,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { serialInput ->
focusManager.clearFocus()
val config = moduleConfig { serial = serialInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("ext_not") {
ExternalNotificationConfigItemList(
ringtone = ringtone,
extNotificationConfig = moduleConfig.externalNotification,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { ringtoneInput, extNotificationInput ->
focusManager.clearFocus()
if (ringtoneInput != ringtone) {
viewModel.setRingtone(destNum, ringtoneInput)
ringtone = ringtoneInput
}
if (extNotificationInput != moduleConfig.externalNotification) {
val config = moduleConfig { externalNotification = extNotificationInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
}
)
}
composable("store_forward") {
StoreForwardConfigItemList(
storeForwardConfig = moduleConfig.storeForward,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { storeForwardInput ->
focusManager.clearFocus()
val config = moduleConfig { storeForward = storeForwardInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("range_test") {
RangeTestConfigItemList(
rangeTestConfig = moduleConfig.rangeTest,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { rangeTestInput ->
focusManager.clearFocus()
val config = moduleConfig { rangeTest = rangeTestInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("telemetry") {
TelemetryConfigItemList(
telemetryConfig = moduleConfig.telemetry,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { telemetryInput ->
focusManager.clearFocus()
val config = moduleConfig { telemetry = telemetryInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("canned_message") {
CannedMessageConfigItemList(
messages = cannedMessageMessages,
cannedMessageConfig = moduleConfig.cannedMessage,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { messagesInput, cannedMessageInput ->
focusManager.clearFocus()
if (messagesInput != cannedMessageMessages) {
viewModel.setCannedMessages(destNum, messagesInput)
cannedMessageMessages = messagesInput
}
if (cannedMessageInput != moduleConfig.cannedMessage) {
val config = moduleConfig { cannedMessage = cannedMessageInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
}
)
}
composable("audio") {
AudioConfigItemList(
audioConfig = moduleConfig.audio,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { audioInput ->
focusManager.clearFocus()
val config = moduleConfig { audio = audioInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
composable("remote_hardware") {
RemoteHardwareConfigItemList(
remoteHardwareConfig = moduleConfig.remoteHardware,
enabled = connected,
focusManager = focusManager,
onSaveClicked = { remoteHardwareInput ->
focusManager.clearFocus()
val config = moduleConfig { remoteHardware = remoteHardwareInput }
viewModel.setModuleConfig(destNum, config)
moduleConfig = config
}
)
}
}
}
@Composable
fun NavCard(
title: String,
enabled: Boolean,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable(enabled = enabled) { onClick() },
elevation = 4.dp
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.body1,
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
modifier = Modifier.weight(1f)
)
Icon(
Icons.TwoTone.KeyboardArrowRight, "trailingIcon",
modifier = Modifier.wrapContentSize(),
)
}
}
}
@Composable
fun NavCard(@StringRes title: Int, enabled: Boolean, onClick: () -> Unit) {
NavCard(title = stringResource(title), enabled = enabled, onClick = onClick)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RadioSettingsScreen(
enabled: Boolean = true,
isLocal: Boolean = true,
headerText: String = "longName",
onRouteClick: (Any) -> Unit = {},
) {
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp)
) {
stickyHeader { TextDividerPreference(headerText) }
item { PreferenceCategory(stringResource(R.string.device_settings)) }
item { NavCard("User", enabled = enabled) { onRouteClick("USER") } }
item { NavCard("Channels", enabled = enabled) { onRouteClick("CHANNELS") } }
items(ConfigDest.values()) { configs ->
NavCard(configs.title, enabled = enabled) { onRouteClick(configs.config) }
}
item { PreferenceCategory(stringResource(R.string.module_settings)) }
items(ModuleDest.values()) { modules ->
NavCard(modules.title, enabled = enabled) { onRouteClick(modules.config) }
}
if (isLocal) {
item { PreferenceCategory("Import / Export") }
item { NavCard("Import configuration", enabled = enabled) { onRouteClick("IMPORT") } }
item { NavCard("Export configuration", enabled = enabled) { onRouteClick("EXPORT") } }
}
}
}
@Preview(showBackground = true)
@Composable
fun RadioSettingsScreenPreview() {
RadioSettingsScreen()
}