feat: settings rework (#4678)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-02 08:51:05 -06:00 committed by GitHub
parent b2b21e10e2
commit fdd07f893f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 941 additions and 306 deletions

View file

@ -0,0 +1,191 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.radio.AdminRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
import org.meshtastic.feature.settings.radio.component.WarningDialog
@Composable
fun AdministrationScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val enabled = state.connected && !state.responseState.isWaiting()
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.administration),
subtitle =
if (state.isLocal) {
destNode?.user?.long_name
} else {
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onBack,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ExpressiveSection(
title = stringResource(Res.string.administration),
titleColor = MaterialTheme.colorScheme.error,
) {
AdminRouteItems(viewModel = viewModel, enabled = enabled, state = state, destNode = destNode)
}
}
}
LoadingOverlay(state = state.responseState)
if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
PacketResponseStateDialog(
state = state.responseState,
onDismiss = { viewModel.clearPacketResponse() },
onComplete = {
viewModel.clearPacketResponse()
onBack()
},
)
}
}
}
@Composable
private fun AdminRouteItems(
viewModel: RadioConfigViewModel,
enabled: Boolean,
state: RadioConfigState,
destNode: Node?,
) {
AdminRoute.entries.forEach { route ->
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
AdminActionDialog(
route = route,
destNode = destNode,
enabled = enabled,
state = state,
onDismiss = { showDialog = false },
onConfirm = { viewModel.setResponseStateLoading(route) },
onPreserveFavoritesChange = { viewModel.setPreserveFavorites(it) },
)
}
ListItem(
enabled = enabled,
text = stringResource(route.title),
leadingIcon = route.icon,
leadingIconTint = MaterialTheme.colorScheme.error,
textColor = MaterialTheme.colorScheme.error,
trailingIcon = null,
) {
showDialog = true
}
}
}
@Composable
private fun AdminActionDialog(
route: AdminRoute,
destNode: Node?,
enabled: Boolean,
state: RadioConfigState,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
onPreserveFavoritesChange: (Boolean) -> Unit,
) {
if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
ShutdownConfirmationDialog(
title = "${stringResource(route.title)}?",
node = destNode,
onDismiss = onDismiss,
isShutdown = route == AdminRoute.SHUTDOWN,
onConfirm = onConfirm,
)
} else {
WarningDialog(
title = "${stringResource(route.title)}?",
text = {
if (route == AdminRoute.NODEDB_RESET) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = stringResource(Res.string.preserve_favorites))
Switch(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
enabled = enabled,
checked = state.nodeDbResetPreserveFavorites,
onCheckedChange = onPreserveFavoritesChange,
)
}
}
},
onDismiss = onDismiss,
onConfirm = onConfirm,
)
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device_configuration
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun DeviceConfigurationScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
onBack: () -> Unit,
onNavigate: (Route) -> Unit,
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.device_configuration),
subtitle =
if (state.isLocal) {
destNode?.user?.long_name
} else {
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onBack,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
ListItem(
text = stringResource(it.title),
leadingIcon = it.icon,
enabled = state.connected && !state.responseState.isWaiting(),
) {
onNavigate(it.route)
}
}
}
}
}
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun ModuleConfigurationScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
excludedModulesUnlocked: Boolean = false,
onBack: () -> Unit,
onNavigate: (Route) -> Unit,
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val modules =
remember(state.metadata, excludedModulesUnlocked) {
if (excludedModulesUnlocked) {
ModuleRoute.entries
} else {
ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role)
}
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.module_settings),
subtitle =
if (state.isLocal) {
destNode?.user?.long_name
} else {
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onBack,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
modules.forEach {
ListItem(
text = stringResource(it.title),
leadingIcon = it.icon,
enabled = state.connected && !state.responseState.isWaiting(),
) {
onNavigate(it.route)
}
}
}
}
}
}

View file

@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -59,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@ -106,14 +106,14 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
@ -125,8 +125,8 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
settingsViewModel: SettingsViewModel = hiltViewModel(),
viewModel: RadioConfigViewModel = hiltViewModel(),
settingsViewModel: SettingsViewModel,
viewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
@ -137,23 +137,6 @@ fun SettingsScreen(
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
PacketResponseStateDialog(
state = state.responseState,
onDismiss = {
isWaiting = false
viewModel.clearPacketResponse()
},
onComplete = {
getNavRouteFrom(state.route)?.let { route ->
isWaiting = false
viewModel.clearPacketResponse()
onNavigate(route)
}
},
)
}
var deviceProfile by remember { mutableStateOf<DeviceProfile?>(null) }
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
@ -241,17 +224,22 @@ fun SettingsScreen(
)
},
) { paddingValues ->
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
node = destNode,
excludedModulesUnlocked = excludedModulesUnlocked,
isOtaCapable = isOtaCapable,
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
onRouteClick = { route ->
isWaiting = true
viewModel.setResponseStateLoading(route)
val navRoute =
when (route) {
is ConfigRoute -> route.route
is ModuleRoute -> route.route
else -> null
}
navRoute?.let { onNavigate(it) }
},
onImport = {
viewModel.clearPacketResponse()
@ -273,7 +261,7 @@ fun SettingsScreen(
val context = LocalContext.current
TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
@ -434,7 +422,7 @@ fun SettingsScreen(
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = null,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.About)
}

View file

@ -37,7 +37,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -47,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
@ -64,8 +64,8 @@ import org.meshtastic.core.ui.component.MainAppBar
@Composable
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
val filterEnabled by viewModel.filterEnabled.collectAsState()
val filterWords by viewModel.filterWords.collectAsState()
val filterEnabled by viewModel.filterEnabled.collectAsStateWithLifecycle()
val filterWords by viewModel.filterWords.collectAsStateWithLifecycle()
var newWord by remember { mutableStateOf("") }
Scaffold(

View file

@ -93,7 +93,7 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im
}
}
val radioConfigRoutes = listOf(LORA, CHANNELS, SECURITY)
val radioConfigRoutes = listOf(USER, LORA, CHANNELS, SECURITY)
fun deviceConfigRoutes(metadata: DeviceMetadata?): List<ConfigRoute> =
filterExcludedFrom(metadata) - radioConfigRoutes

View file

@ -33,12 +33,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.resources.Res
@ -56,9 +56,9 @@ import org.meshtastic.core.ui.component.NodeChip
*/
@Composable
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
val olderThanDays by viewModel.olderThanDays.collectAsState()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
val olderThanDays by viewModel.olderThanDays.collectAsStateWithLifecycle()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsStateWithLifecycle()
val nodesToDelete by viewModel.nodesToDelete.collectAsStateWithLifecycle()
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }

View file

@ -18,37 +18,37 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.AdminPanelSettings
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Restore
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@ -66,18 +66,13 @@ import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.message_device_managed
import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.nodedb_reset
import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.radio_configuration
import org.meshtastic.core.resources.reboot
import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
import org.meshtastic.feature.settings.radio.component.WarningDialog
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -85,30 +80,16 @@ import org.meshtastic.feature.settings.radio.component.WarningDialog
fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
node: Node? = null,
excludedModulesUnlocked: Boolean = false,
isOtaCapable: Boolean = false,
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
var modules by remember {
mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
}
LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
if (excludedModulesUnlocked) {
modules = ModuleRoute.entries
} else {
modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
}
}
Column {
TitledCard(title = stringResource(Res.string.radio_configuration)) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
@ -117,126 +98,122 @@ fun RadioConfigItemList(
}
}
TitledCard(title = stringResource(Res.string.device_configuration), modifier = Modifier.padding(top = 16.dp)) {
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
if (isManaged) {
ManagedMessage()
}
ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
}
}
TitledCard(title = stringResource(Res.string.module_settings), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
modules.forEach {
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
}
}
}
if (state.isLocal) {
TitledCard(title = stringResource(Res.string.backup_restore), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.import_configuration),
leadingIcon = Icons.Rounded.Download,
text = stringResource(Res.string.device_configuration),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
onClick = onImport,
)
ListItem(
text = stringResource(Res.string.export_configuration),
leadingIcon = Icons.Rounded.Upload,
enabled = enabled,
onClick = onExport,
)
}
}
TitledCard(title = stringResource(Res.string.administration), modifier = Modifier.padding(top = 16.dp)) {
AdminRoute.entries.forEach { route ->
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
// Use enhanced confirmation for SHUTDOWN and REBOOT
if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
ShutdownConfirmationDialog(
title = "${stringResource(route.title)}?",
node = node,
onDismiss = { showDialog = false },
isShutdown = route == AdminRoute.SHUTDOWN,
onConfirm = { onRouteClick(route) },
)
} else {
WarningDialog(
title = "${stringResource(route.title)}?",
text = {
if (route == AdminRoute.NODEDB_RESET) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = stringResource(Res.string.preserve_favorites))
Switch(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
enabled = enabled,
checked = state.nodeDbResetPreserveFavorites,
onCheckedChange = onPreserveFavoritesToggle,
)
}
}
},
onDismiss = { showDialog = false },
onConfirm = { onRouteClick(route) },
)
}
}
ListItem(
enabled = enabled,
text = stringResource(route.title),
leadingIcon = route.icon,
trailingIcon = null,
) {
showDialog = true
onNavigate(SettingsRoutes.DeviceConfiguration)
}
}
}
if (state.isLocal) {
TitledCard(title = stringResource(Res.string.advanced_title), modifier = Modifier.padding(top = 16.dp)) {
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.module_settings),
leadingIcon = Icons.Rounded.Settings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
) {
onNavigate(SettingsRoutes.ModuleConfiguration)
}
}
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
if (isManaged) {
ManagedMessage()
}
if (isOtaCapable) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,
text = stringResource(Res.string.import_configuration),
leadingIcon = Icons.Rounded.Download,
enabled = enabled,
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
onClick = onImport,
)
ListItem(
text = stringResource(Res.string.export_configuration),
leadingIcon = Icons.Rounded.Upload,
enabled = enabled,
onClick = onExport,
)
}
ListItem(
text = stringResource(Res.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
ListItem(
text = stringResource(Res.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
ExpressiveSection(title = stringResource(Res.string.administration)) {
ListItem(
text = stringResource(Res.string.administration),
leadingIcon = Icons.Rounded.AdminPanelSettings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
leadingIconTint = MaterialTheme.colorScheme.error,
textColor = MaterialTheme.colorScheme.error,
trailingIconTint = MaterialTheme.colorScheme.error,
enabled = enabled,
) {
onNavigate(SettingsRoutes.Administration)
}
}
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
if (isManaged) {
ManagedMessage()
}
if (isOtaCapable) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,
enabled = enabled,
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
)
}
ListItem(
text = stringResource(Res.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
ListItem(
text = stringResource(Res.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}
}
}
@Composable
fun ExpressiveSection(
title: String,
modifier: Modifier = Modifier,
titleColor: Color = MaterialTheme.colorScheme.primary,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = titleColor,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
content = content,
)
}
}

View file

@ -181,6 +181,12 @@ constructor(
.onEach { lc -> if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(radioConfig = lc) } }
.launchIn(viewModelScope)
radioConfigRepository.channelSetFlow
.onEach { cs ->
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(channelList = cs.settings) }
}
.launchIn(viewModelScope)
radioConfigRepository.moduleConfigFlow
.onEach { lmc ->
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(moduleConfig = lmc) }
@ -608,16 +614,7 @@ constructor(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update {
RadioConfigState(
isLocal = it.isLocal,
connected = it.connected,
route = route.name,
metadata = it.metadata,
nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
responseState = ResponseState.Loading(),
)
}
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {
ConfigRoute.USER -> getOwner(destNum)
@ -862,6 +859,14 @@ constructor(
sendAdminRequest(destNum)
}
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
clearPacketResponse()
} else if (route.isEmpty()) {
setResponseStateSuccess()
}
}
}
}
}

View file

@ -67,11 +67,13 @@ import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@ -80,20 +82,24 @@ import org.meshtastic.proto.Config
fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
if (state.responseState.isWaiting()) {
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
}
Box(modifier = Modifier.fillMaxSize()) {
ChannelConfigScreen(
title = stringResource(Res.string.channels),
onBack = onBack,
settingsList = state.channelList,
loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
maxChannels = viewModel.maxChannels,
firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
enabled = state.connected,
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
)
ChannelConfigScreen(
title = stringResource(Res.string.channels),
onBack = onBack,
settingsList = state.channelList,
loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
maxChannels = viewModel.maxChannels,
firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
enabled = state.connected,
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
)
LoadingOverlay(state = state.responseState)
if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.meshtastic.feature.settings.radio.ResponseState
private const val LOADING_OVERLAY_ALPHA = 0.8f
private const val PERCENTAGE_FACTOR = 100
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
AnimatedVisibility(visible = state is ResponseState.Loading, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier =
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = LOADING_OVERLAY_ALPHA))
.clickable(enabled = false) {},
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.padding(32.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
if (state is ResponseState.Loading) {
val progress by
animateFloatAsState(
targetValue = state.completed.toFloat() / state.total.toFloat(),
label = "loading_progress",
)
Box(contentAlignment = Alignment.Center) {
CircularWavyProgressIndicator(
progress = { progress },
modifier = Modifier.size(80.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Text(
text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
state.status?.let { status ->
Text(
text = status,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}

View file

@ -18,10 +18,17 @@ package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -29,19 +36,23 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.delivery_confirmed_reboot_warning
import org.meshtastic.core.resources.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
@ -49,54 +60,139 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
if (state is ResponseState.Success) {
delay(AUTO_DISMISS_DELAY_MS)
onDismiss()
backDispatcher?.onBackPressed()
}
}
MeshtasticDialog(
onDismiss = onDismiss,
title = "", // Title is handled in the text block for more control
onDismiss = if (state is ResponseState.Loading) onDismiss else null,
title = null,
icon = null,
text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (state is ResponseState.Loading) {
val progress by
animateFloatAsState(
targetValue = state.completed.toFloat() / state.total.toFloat(),
label = "progress",
)
Text("%.0f%%".format(progress * 100))
LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
state.status?.let {
Text(
text = it,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodySmall,
)
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
when (state) {
is ResponseState.Loading -> {
LoadingContent(state = state, onComplete = onComplete)
}
if (state.completed >= state.total) onComplete()
}
if (state is ResponseState.Success) {
Text(text = stringResource(Res.string.delivery_confirmed))
}
if (state is ResponseState.Error) {
Text(text = stringResource(Res.string.error), minLines = 2)
Text(text = state.error.asString())
is ResponseState.Success -> {
SuccessContent()
}
is ResponseState.Error -> {
ErrorContent(state = state)
}
ResponseState.Empty -> {}
}
}
},
dismissable = false,
onConfirm = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
onConfirm =
if (state !is ResponseState.Loading) {
{
onDismiss()
backDispatcher?.onBackPressed()
}
} else {
null
},
confirmText = stringResource(Res.string.close),
dismissText = null, // Hide dismiss button, only show "Close" confirm button
dismissText = if (state is ResponseState.Loading) stringResource(Res.string.cancel) else null,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) {
val progress by
animateFloatAsState(targetValue = state.completed.toFloat() / state.total.toFloat(), label = "progress")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "%.0f%%".format(progress * 100),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.secondary,
)
LinearWavyProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
state.status?.let {
Text(
text = it,
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
if (state.completed >= state.total) onComplete()
}
@Composable
private fun SuccessContent() {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(84.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(Res.string.delivery_confirmed),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.delivery_confirmed_reboot_warning),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ErrorContent(state: ResponseState.Error) {
Icon(
imageVector = Icons.Filled.Error,
contentDescription = null,
modifier = Modifier.size(84.dp),
tint = MaterialTheme.colorScheme.error,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(Res.string.error),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error,
)
Text(
text = "${state.error.asString()}.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
@Preview(showBackground = true)
@Composable
private fun PacketResponseStateDialogPreview() {
private fun PacketResponseStateDialogLoadingPreview() {
PacketResponseStateDialog(state = ResponseState.Loading(total = 17, completed = 5))
}
@Preview(showBackground = true)
@Composable
private fun PacketResponseStateDialogSuccessPreview() {
PacketResponseStateDialog(state = ResponseState.Success(Unit))
}
@Preview(showBackground = true)
@Composable
private fun PacketResponseStateDialogErrorPreview() {
PacketResponseStateDialog(
state = ResponseState.Error(org.meshtastic.core.resources.UiText.DynamicString("Failed to send packet")),
)
}

View file

@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -58,55 +59,58 @@ fun <T : Message<T, *>> RadioConfigScreenList(
) {
val focusManager = LocalFocusManager.current
if (responseState.isWaiting()) {
PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
}
Box(modifier = modifier) {
Scaffold(
topBar = {
MainAppBar(
title = title,
canNavigateUp = true,
onNavigateUp = onBack,
ourNode = null,
showNodeChip = false,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
val showFooterButtons = configState.isDirty || additionalDirtyCheck()
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = title,
canNavigateUp = true,
onNavigateUp = onBack,
ourNode = null,
showNodeChip = false,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
val showFooterButtons = configState.isDirty || additionalDirtyCheck()
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
content()
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
content()
item {
AnimatedVisibility(
visible = showFooterButtons,
enter = fadeIn() + expandIn(),
exit = fadeOut() + shrinkOut(),
) {
PreferenceFooter(
enabled = enabled && showFooterButtons,
negativeText = stringResource(Res.string.discard_changes),
onNegativeClicked = {
focusManager.clearFocus()
configState.reset()
onDiscard()
},
positiveText = stringResource(Res.string.save_changes),
onPositiveClicked = {
focusManager.clearFocus()
onSave(configState.value)
},
)
item {
AnimatedVisibility(
visible = showFooterButtons,
enter = fadeIn() + expandIn(),
exit = fadeOut() + shrinkOut(),
) {
PreferenceFooter(
enabled = enabled && showFooterButtons,
negativeText = stringResource(Res.string.discard_changes),
onNegativeClicked = {
focusManager.clearFocus()
configState.reset()
onDiscard()
},
positiveText = stringResource(Res.string.save_changes),
onPositiveClicked = {
focusManager.clearFocus()
onSave(configState.value)
},
)
}
}
}
}
LoadingOverlay(state = responseState)
if (responseState is ResponseState.Success || responseState is ResponseState.Error) {
PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
}
}
}