mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: settings rework (#4678)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b2b21e10e2
commit
fdd07f893f
27 changed files with 941 additions and 306 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue