From fdd07f893f9cec47edc9aabcdfe8273e333c3457 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:51:05 -0600 Subject: [PATCH] feat: settings rework (#4678) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/navigation/SettingsNavigation.kt | 41 ++- .../repository/radio/RadioInterfaceService.kt | 8 +- .../mesh/service/MeshCommandSender.kt | 4 + .../geeksville/mesh/service/MeshService.kt | 8 +- .../org/meshtastic/core/ble/BleConnection.kt | 6 +- .../core/common/util/SequentialJob.kt | 25 +- .../SwitchingNodeInfoReadDataSource.kt | 8 +- .../SwitchingNodeInfoWriteDataSource.kt | 30 ++- .../core/database/DatabaseManager.kt | 11 +- .../org/meshtastic/core/navigation/Routes.kt | 6 + .../composeResources/values/strings.xml | 1 + .../core/service/ServiceRepository.kt | 2 +- .../feature/node/metrics/NeighborInfoLog.kt | 3 +- .../feature/node/metrics/TracerouteLog.kt | 3 +- .../feature/settings/AdministrationScreen.kt | 191 ++++++++++++++ .../settings/DeviceConfigurationScreen.kt | 88 +++++++ .../settings/ModuleConfigurationScreen.kt | 99 +++++++ .../feature/settings/SettingsScreen.kt | 50 ++-- .../settings/filter/FilterSettingsScreen.kt | 6 +- .../settings/navigation/ConfigRoute.kt | 2 +- .../settings/radio/CleanNodeDatabaseScreen.kt | 8 +- .../feature/settings/radio/RadioConfig.kt | 241 ++++++++---------- .../settings/radio/RadioConfigViewModel.kt | 25 +- .../radio/channel/ChannelConfigScreen.kt | 32 ++- .../radio/component/LoadingOverlay.kt | 97 +++++++ .../component/PacketResponseStateDialog.kt | 158 +++++++++--- .../radio/component/RadioConfigScreenList.kt | 94 +++---- 27 files changed, 941 insertions(+), 306 deletions(-) create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt index 18522c531..eacec7cb3 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt @@ -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 { backStackEntry -> + val parentEntry = + remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) } + DeviceConfigurationScreen( + viewModel = hiltViewModel(parentEntry), + onBack = navController::popBackStack, + onNavigate = { route -> navController.navigate(route) }, + ) + } + + composable { 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 { backStackEntry -> + val parentEntry = + remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) } + AdministrationScreen(viewModel = hiltViewModel(parentEntry), onBack = navController::popBackStack) + } + composable( 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) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 7d1ebfbd5..0e7215d5c 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -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.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _receivedData = simpleSharedFlow() + private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) val receivedData: SharedFlow = _receivedData - private val _connectionError = simpleSharedFlow() + private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) val connectionError: SharedFlow = _connectionError.asSharedFlow() // Thread-safe StateFlow for tracking device address changes @@ -371,7 +371,7 @@ constructor( serviceScope.handledLaunch { handleSendToRadio(a) } } - private val _meshActivity = simpleSharedFlow() + private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64) val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private fun emitSendActivity() { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index 48497a762..3b36c9e19 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index db1a6066f..2f01f3368 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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 = nodeManager.getNodes() diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt index 0e3982421..1ec635cc6 100644 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt +++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt @@ -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() diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt index 730252c62..564c66515 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt @@ -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(null) + private val job = AtomicReference() /** * 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) } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt index 622da459a..35d9c0848 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt @@ -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 . */ - 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 = - dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } + dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList() - override suspend fun getUnknownNodes(): List = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } + override suspend fun getUnknownNodes(): List = + dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList() } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt index c201cab03..6b5501910 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt @@ -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) = + override suspend fun installConfig(mi: MyNodeEntity, nodes: List) { 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) = + override suspend fun deleteNodes(nodeNums: List) { 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() } } + } } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 7754211bb..3ae7d49f7 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -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 withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value) + suspend fun 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 { diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index 7aba5f310..660a20e4e 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index b77231ac7..11695a4c3 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -338,6 +338,7 @@ Direct Message NodeDB reset Delivery confirmed + Your device may disconnect and reboot while settings are applied. Error Ignore Remove from ignored diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 2137061f3..77f2b49c0 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -98,7 +98,7 @@ class ServiceRepository @Inject constructor() { } } - private val _meshPacketFlow = MutableSharedFlow() + private val _meshPacketFlow = MutableSharedFlow(extraBufferCapacity = 64) val meshPacketFlow: SharedFlow get() = _meshPacketFlow diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt index d626be2d4..006e02fcf 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -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), diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index dcadc596d..1fdd5cf5b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -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), diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt new file mode 100644 index 000000000..1d5c16f4e --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt @@ -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 . + */ +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, + ) + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt new file mode 100644 index 000000000..77dc42419 --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt @@ -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 . + */ +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) + } + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt new file mode 100644 index 000000000..630d19c0b --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt @@ -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 . + */ +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) + } + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 1887edbb3..bd5ebc655 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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(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) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt index 9a2c1f0ee..0c8737e52 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt @@ -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( diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt index 1821fd6c3..9c6bb2cc8 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt @@ -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 = filterExcludedFrom(metadata) - radioConfigRoutes diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt index 06c5a853b..51ca46704 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt @@ -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() } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index d84cad310..b87987539 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -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, + ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index ec9d29c5c..2cb947c8f 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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() + } + } } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 5915c54aa..30c5c8214 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -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") diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt new file mode 100644 index 000000000..18ade8df5 --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -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 . + */ +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, + ) + } + } + } + } + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt index 366f8669c..1f7e42681 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt @@ -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 PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher @@ -49,54 +60,139 @@ fun PacketResponseStateDialog(state: ResponseState, 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")), + ) +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index 71b5ffb41..15396a60b 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt @@ -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 > 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) + } } }