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
27 changed files with 941 additions and 306 deletions

View File

@@ -19,8 +19,11 @@
package com.geeksville.mesh.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
@@ -32,7 +35,11 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -76,6 +83,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
settingsViewModel = hiltViewModel(parentEntry),
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
@@ -84,10 +92,39 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}
},
) {
navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
navController.navigate(it)
}
}
composable<SettingsRoutes.DeviceConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
DeviceConfigurationScreen(
viewModel = hiltViewModel(parentEntry),
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}
composable<SettingsRoutes.ModuleConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
val settingsViewModel: SettingsViewModel = hiltViewModel(parentEntry)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = hiltViewModel(parentEntry),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}
composable<SettingsRoutes.Administration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
AdministrationScreen(viewModel = hiltViewModel(parentEntry), onBack = navController::popBackStack)
}
composable<SettingsRoutes.CleanNodeDb>(
deepLinks =
listOf(
@@ -104,6 +141,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
@@ -133,6 +171,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
@@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _receivedData = simpleSharedFlow<ByteArray>()
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
val receivedData: SharedFlow<ByteArray> = _receivedData
private val _connectionError = simpleSharedFlow<BleError>()
private val _connectionError = MutableSharedFlow<BleError>(extraBufferCapacity = 64)
val connectionError: SharedFlow<BleError> = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
@@ -371,7 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}
private val _meshActivity = simpleSharedFlow<MeshActivity>()
private val _meshActivity = MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64)
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private fun emitSendActivity() {

View File

@@ -94,6 +94,10 @@ constructor(
radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
}
fun getCachedLocalConfig(): LocalConfig = localConfig.value
fun getCachedChannelSet(): ChannelSet = channelSet.value
@VisibleForTesting internal constructor() : this(null, null, null, null)
fun getCurrentPacketId(): Long = currentPacketId.get()

View File

@@ -31,10 +31,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
@@ -249,9 +247,7 @@ class MeshService : Service() {
override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
override fun getConfig(): ByteArray = toRemoteExceptions {
runBlocking { radioConfigRepository.localConfigFlow.first().encode() }
}
override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() }
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetConfig(payload, myNodeNum)
@@ -310,7 +306,7 @@ class MeshService : Service() {
}
override fun getChannelSet(): ByteArray = toRemoteExceptions {
runBlocking { radioConfigRepository.channelSetFlow.first().encode() }
commandSender.getCachedChannelSet().encode()
}
override fun getNodes(): List<NodeInfo> = nodeManager.getNodes()

View File

@@ -19,6 +19,7 @@ package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
@@ -72,7 +74,7 @@ class BleConnection(
*
* @param p The peripheral to connect to.
*/
suspend fun connect(p: Peripheral) {
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p
@@ -156,7 +158,7 @@ class BleConnection(
}
/** Disconnects from the current peripheral. */
suspend fun disconnect() {
suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()

View File

@@ -16,8 +16,11 @@
*/
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@@ -26,15 +29,31 @@ import javax.inject.Inject
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>(null)
private val job = AtomicReference<Job?>()
/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
*
* @param timeoutMs Optional timeout in milliseconds. If > 0, the [block] is wrapped in [withTimeout] so that
* indefinitely-suspended coroutines (e.g. blocked DataStore reads) throw [TimeoutCancellationException] instead
* of hanging silently.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
fun launch(scope: CoroutineScope, timeoutMs: Long = 0, block: suspend CoroutineScope.() -> Unit) {
cancel()
val newJob = scope.handledLaunch(block = block)
val newJob =
scope.handledLaunch {
if (timeoutMs > 0) {
try {
withTimeout(timeoutMs, block)
} catch (e: TimeoutCancellationException) {
Logger.w { "SequentialJob timed out after ${timeoutMs}ms" }
throw e
}
} else {
block()
}
}
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@@ -14,7 +14,6 @@
* 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.core.data.datasource
import kotlinx.coroutines.flow.Flow
@@ -54,7 +53,8 @@ class SwitchingNodeInfoReadDataSource @Inject constructor(private val dbManager:
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) }
dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList()
override suspend fun getUnknownNodes(): List<NodeEntity> = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() }
override suspend fun getUnknownNodes(): List<NodeEntity> =
dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList()
}

View File

@@ -33,33 +33,43 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {
override suspend fun upsert(node: NodeEntity) =
override suspend fun upsert(node: NodeEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
}
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
}
override suspend fun clearNodeDB(preserveFavorites: Boolean) =
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
}
override suspend fun clearMyNodeInfo() =
override suspend fun clearMyNodeInfo() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
}
override suspend fun deleteNode(num: Int) =
override suspend fun deleteNode(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
}
override suspend fun deleteNodes(nodeNums: List<Int>) =
override suspend fun deleteNodes(nodeNums: List<Int>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
}
override suspend fun deleteMetadata(num: Int) =
override suspend fun deleteMetadata(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
}
override suspend fun upsert(metadata: MetadataEntity) =
override suspend fun upsert(metadata: MetadataEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
}
override suspend fun setNodeNotes(num: Int, notes: String) =
override suspend fun setNodeNotes(num: Int, notes: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
}
override suspend fun backfillDenormalizedNames() =
override suspend fun backfillDenormalizedNames() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
}
}

View File

@@ -23,6 +23,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -44,6 +45,7 @@ import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@@ -114,8 +116,15 @@ class DatabaseManager @Inject constructor(private val app: Application, private
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
private val limitedIo = dispatchers.io.limitedParallelism(4)
/** Execute [block] with the current DB instance. */
inline fun <T> withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
val active = _currentDb.value?.openHelper?.databaseName ?: return@withContext null
markLastUsed(active)
val db = _currentDb.value ?: return@withContext null // Use the cached current DB
block(db)
}
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {

View File

@@ -91,6 +91,12 @@ object SettingsRoutes {
@Serializable data class Settings(val destNum: Int? = null) : Route
@Serializable data object DeviceConfiguration : Route
@Serializable data object ModuleConfiguration : Route
@Serializable data object Administration : Route
// region radio Config Routes
@Serializable data object User : Route

View File

@@ -338,6 +338,7 @@
<string name="direct_message">Direct Message</string>
<string name="nodedb_reset">NodeDB reset</string>
<string name="delivery_confirmed">Delivery confirmed</string>
<string name="delivery_confirmed_reboot_warning">Your device may disconnect and reboot while settings are applied.</string>
<string name="error">Error</string>
<string name="ignore">Ignore</string>
<string name="remove_ignored">Remove from ignored</string>

View File

@@ -98,7 +98,7 @@ class ServiceRepository @Inject constructor() {
}
}
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>()
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(extraBufferCapacity = 64)
val meshPacketFlow: SharedFlow<MeshPacket>
get() = _meshPacketFlow

View File

@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -90,7 +89,7 @@ fun NeighborInfoLogScreen(
Scaffold(
topBar = {
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.neighbor_info),

View File

@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -111,7 +110,7 @@ fun TracerouteLogScreen(
Scaffold(
topBar = {
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.traceroute_log),

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)
}
}
}