mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
feat: settings rework (#4678)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.administration
|
||||
import org.meshtastic.core.resources.preserve_favorites
|
||||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.radio.AdminRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigState
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
|
||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
|
||||
import org.meshtastic.feature.settings.radio.component.WarningDialog
|
||||
|
||||
@Composable
|
||||
fun AdministrationScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
val enabled = state.connected && !state.responseState.isWaiting()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.administration),
|
||||
subtitle =
|
||||
if (state.isLocal) {
|
||||
destNode?.user?.long_name
|
||||
} else {
|
||||
val remoteName = destNode?.user?.long_name ?: ""
|
||||
stringResource(Res.string.remotely_administrating, remoteName)
|
||||
},
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ExpressiveSection(
|
||||
title = stringResource(Res.string.administration),
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
) {
|
||||
AdminRouteItems(viewModel = viewModel, enabled = enabled, state = state, destNode = destNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadingOverlay(state = state.responseState)
|
||||
|
||||
if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
|
||||
PacketResponseStateDialog(
|
||||
state = state.responseState,
|
||||
onDismiss = { viewModel.clearPacketResponse() },
|
||||
onComplete = {
|
||||
viewModel.clearPacketResponse()
|
||||
onBack()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminRouteItems(
|
||||
viewModel: RadioConfigViewModel,
|
||||
enabled: Boolean,
|
||||
state: RadioConfigState,
|
||||
destNode: Node?,
|
||||
) {
|
||||
AdminRoute.entries.forEach { route ->
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
if (showDialog) {
|
||||
AdminActionDialog(
|
||||
route = route,
|
||||
destNode = destNode,
|
||||
enabled = enabled,
|
||||
state = state,
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = { viewModel.setResponseStateLoading(route) },
|
||||
onPreserveFavoritesChange = { viewModel.setPreserveFavorites(it) },
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
enabled = enabled,
|
||||
text = stringResource(route.title),
|
||||
leadingIcon = route.icon,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
showDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminActionDialog(
|
||||
route: AdminRoute,
|
||||
destNode: Node?,
|
||||
enabled: Boolean,
|
||||
state: RadioConfigState,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onPreserveFavoritesChange: (Boolean) -> Unit,
|
||||
) {
|
||||
if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
|
||||
ShutdownConfirmationDialog(
|
||||
title = "${stringResource(route.title)}?",
|
||||
node = destNode,
|
||||
onDismiss = onDismiss,
|
||||
isShutdown = route == AdminRoute.SHUTDOWN,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
} else {
|
||||
WarningDialog(
|
||||
title = "${stringResource(route.title)}?",
|
||||
text = {
|
||||
if (route == AdminRoute.NODEDB_RESET) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = stringResource(Res.string.preserve_favorites))
|
||||
Switch(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
enabled = enabled,
|
||||
checked = state.nodeDbResetPreserveFavorites,
|
||||
onCheckedChange = onPreserveFavoritesChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.device_configuration
|
||||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
||||
@Composable
|
||||
fun DeviceConfigurationScreen(
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
onBack: () -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.device_configuration),
|
||||
subtitle =
|
||||
if (state.isLocal) {
|
||||
destNode?.user?.long_name
|
||||
} else {
|
||||
val remoteName = destNode?.user?.long_name ?: ""
|
||||
stringResource(Res.string.remotely_administrating, remoteName)
|
||||
},
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
|
||||
ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
|
||||
ListItem(
|
||||
text = stringResource(it.title),
|
||||
leadingIcon = it.icon,
|
||||
enabled = state.connected && !state.responseState.isWaiting(),
|
||||
) {
|
||||
onNavigate(it.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.module_settings
|
||||
import org.meshtastic.core.resources.remotely_administrating
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
||||
@Composable
|
||||
fun ModuleConfigurationScreen(
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
excludedModulesUnlocked: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
|
||||
val modules =
|
||||
remember(state.metadata, excludedModulesUnlocked) {
|
||||
if (excludedModulesUnlocked) {
|
||||
ModuleRoute.entries
|
||||
} else {
|
||||
ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.module_settings),
|
||||
subtitle =
|
||||
if (state.isLocal) {
|
||||
destNode?.user?.long_name
|
||||
} else {
|
||||
val remoteName = destNode?.user?.long_name ?: ""
|
||||
stringResource(Res.string.remotely_administrating, remoteName)
|
||||
},
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
|
||||
modules.forEach {
|
||||
ListItem(
|
||||
text = stringResource(it.title),
|
||||
leadingIcon = it.icon,
|
||||
enabled = state.connected && !state.responseState.isWaiting(),
|
||||
) {
|
||||
onNavigate(it.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -59,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
@@ -106,14 +106,14 @@ import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.ExpressiveSection
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
|
||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.feature.settings.util.LanguageUtils
|
||||
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
|
||||
import org.meshtastic.proto.DeviceProfile
|
||||
@@ -125,8 +125,8 @@ import kotlin.time.Duration.Companion.seconds
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
settingsViewModel: SettingsViewModel,
|
||||
viewModel: RadioConfigViewModel,
|
||||
onClickNodeChip: (Int) -> Unit = {},
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
) {
|
||||
@@ -137,23 +137,6 @@ fun SettingsScreen(
|
||||
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
|
||||
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
var isWaiting by remember { mutableStateOf(false) }
|
||||
if (isWaiting) {
|
||||
PacketResponseStateDialog(
|
||||
state = state.responseState,
|
||||
onDismiss = {
|
||||
isWaiting = false
|
||||
viewModel.clearPacketResponse()
|
||||
},
|
||||
onComplete = {
|
||||
getNavRouteFrom(state.route)?.let { route ->
|
||||
isWaiting = false
|
||||
viewModel.clearPacketResponse()
|
||||
onNavigate(route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var deviceProfile by remember { mutableStateOf<DeviceProfile?>(null) }
|
||||
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
|
||||
@@ -241,17 +224,22 @@ fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
RadioConfigItemList(
|
||||
state = state,
|
||||
isManaged = localConfig.security?.is_managed ?: false,
|
||||
node = destNode,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
isOtaCapable = isOtaCapable,
|
||||
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
|
||||
onRouteClick = { route ->
|
||||
isWaiting = true
|
||||
viewModel.setResponseStateLoading(route)
|
||||
val navRoute =
|
||||
when (route) {
|
||||
is ConfigRoute -> route.route
|
||||
is ModuleRoute -> route.route
|
||||
else -> null
|
||||
}
|
||||
navRoute?.let { onNavigate(it) }
|
||||
},
|
||||
onImport = {
|
||||
viewModel.clearPacketResponse()
|
||||
@@ -273,7 +261,7 @@ fun SettingsScreen(
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
|
||||
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
|
||||
if (state.analyticsAvailable) {
|
||||
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
|
||||
SwitchListItem(
|
||||
@@ -434,7 +422,7 @@ fun SettingsScreen(
|
||||
ListItem(
|
||||
text = stringResource(Res.string.acknowledgements),
|
||||
leadingIcon = Icons.Rounded.Info,
|
||||
trailingIcon = null,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.About)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,6 +46,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add
|
||||
@@ -64,8 +64,8 @@ import org.meshtastic.core.ui.component.MainAppBar
|
||||
|
||||
@Composable
|
||||
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
|
||||
val filterEnabled by viewModel.filterEnabled.collectAsState()
|
||||
val filterWords by viewModel.filterWords.collectAsState()
|
||||
val filterEnabled by viewModel.filterEnabled.collectAsStateWithLifecycle()
|
||||
val filterWords by viewModel.filterWords.collectAsStateWithLifecycle()
|
||||
var newWord by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
|
||||
@@ -93,7 +93,7 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im
|
||||
}
|
||||
}
|
||||
|
||||
val radioConfigRoutes = listOf(LORA, CHANNELS, SECURITY)
|
||||
val radioConfigRoutes = listOf(USER, LORA, CHANNELS, SECURITY)
|
||||
|
||||
fun deviceConfigRoutes(metadata: DeviceMetadata?): List<ConfigRoute> =
|
||||
filterExcludedFrom(metadata) - radioConfigRoutes
|
||||
|
||||
@@ -33,12 +33,12 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -56,9 +56,9 @@ import org.meshtastic.core.ui.component.NodeChip
|
||||
*/
|
||||
@Composable
|
||||
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
|
||||
val olderThanDays by viewModel.olderThanDays.collectAsState()
|
||||
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
|
||||
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
|
||||
val olderThanDays by viewModel.olderThanDays.collectAsStateWithLifecycle()
|
||||
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsStateWithLifecycle()
|
||||
val nodesToDelete by viewModel.nodesToDelete.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
|
||||
|
||||
|
||||
@@ -18,37 +18,37 @@ package org.meshtastic.feature.settings.radio
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.AdminPanelSettings
|
||||
import androidx.compose.material.icons.rounded.AppSettingsAlt
|
||||
import androidx.compose.material.icons.rounded.BugReport
|
||||
import androidx.compose.material.icons.rounded.CleaningServices
|
||||
import androidx.compose.material.icons.rounded.Download
|
||||
import androidx.compose.material.icons.rounded.PowerSettingsNew
|
||||
import androidx.compose.material.icons.rounded.RestartAlt
|
||||
import androidx.compose.material.icons.rounded.Restore
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material.icons.rounded.SystemUpdate
|
||||
import androidx.compose.material.icons.rounded.Upload
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.navigation.FirmwareRoutes
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
@@ -66,18 +66,13 @@ import org.meshtastic.core.resources.import_configuration
|
||||
import org.meshtastic.core.resources.message_device_managed
|
||||
import org.meshtastic.core.resources.module_settings
|
||||
import org.meshtastic.core.resources.nodedb_reset
|
||||
import org.meshtastic.core.resources.preserve_favorites
|
||||
import org.meshtastic.core.resources.radio_configuration
|
||||
import org.meshtastic.core.resources.reboot
|
||||
import org.meshtastic.core.resources.shutdown
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
|
||||
import org.meshtastic.feature.settings.radio.component.WarningDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@@ -85,30 +80,16 @@ import org.meshtastic.feature.settings.radio.component.WarningDialog
|
||||
fun RadioConfigItemList(
|
||||
state: RadioConfigState,
|
||||
isManaged: Boolean,
|
||||
node: Node? = null,
|
||||
excludedModulesUnlocked: Boolean = false,
|
||||
isOtaCapable: Boolean = false,
|
||||
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
|
||||
onRouteClick: (Enum<*>) -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
onExport: () -> Unit = {},
|
||||
onNavigate: (Route) -> Unit,
|
||||
) {
|
||||
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
|
||||
var modules by remember {
|
||||
mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
|
||||
}
|
||||
|
||||
LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
|
||||
if (excludedModulesUnlocked) {
|
||||
modules = ModuleRoute.entries
|
||||
} else {
|
||||
modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
TitledCard(title = stringResource(Res.string.radio_configuration)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
@@ -117,126 +98,122 @@ fun RadioConfigItemList(
|
||||
}
|
||||
}
|
||||
|
||||
TitledCard(title = stringResource(Res.string.device_configuration), modifier = Modifier.padding(top = 16.dp)) {
|
||||
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
|
||||
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
}
|
||||
|
||||
TitledCard(title = stringResource(Res.string.module_settings), modifier = Modifier.padding(top = 16.dp)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
modules.forEach {
|
||||
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLocal) {
|
||||
TitledCard(title = stringResource(Res.string.backup_restore), modifier = Modifier.padding(top = 16.dp)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.import_configuration),
|
||||
leadingIcon = Icons.Rounded.Download,
|
||||
text = stringResource(Res.string.device_configuration),
|
||||
leadingIcon = Icons.Rounded.AppSettingsAlt,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
onClick = onImport,
|
||||
)
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_configuration),
|
||||
leadingIcon = Icons.Rounded.Upload,
|
||||
enabled = enabled,
|
||||
onClick = onExport,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TitledCard(title = stringResource(Res.string.administration), modifier = Modifier.padding(top = 16.dp)) {
|
||||
AdminRoute.entries.forEach { route ->
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
if (showDialog) {
|
||||
// Use enhanced confirmation for SHUTDOWN and REBOOT
|
||||
if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
|
||||
ShutdownConfirmationDialog(
|
||||
title = "${stringResource(route.title)}?",
|
||||
node = node,
|
||||
onDismiss = { showDialog = false },
|
||||
isShutdown = route == AdminRoute.SHUTDOWN,
|
||||
onConfirm = { onRouteClick(route) },
|
||||
)
|
||||
} else {
|
||||
WarningDialog(
|
||||
title = "${stringResource(route.title)}?",
|
||||
text = {
|
||||
if (route == AdminRoute.NODEDB_RESET) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = stringResource(Res.string.preserve_favorites))
|
||||
Switch(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
enabled = enabled,
|
||||
checked = state.nodeDbResetPreserveFavorites,
|
||||
onCheckedChange = onPreserveFavoritesToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = { onRouteClick(route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
enabled = enabled,
|
||||
text = stringResource(route.title),
|
||||
leadingIcon = route.icon,
|
||||
trailingIcon = null,
|
||||
) {
|
||||
showDialog = true
|
||||
onNavigate(SettingsRoutes.DeviceConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLocal) {
|
||||
TitledCard(title = stringResource(Res.string.advanced_title), modifier = Modifier.padding(top = 16.dp)) {
|
||||
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
ListItem(
|
||||
text = stringResource(Res.string.module_settings),
|
||||
leadingIcon = Icons.Rounded.Settings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.ModuleConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLocal) {
|
||||
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isOtaCapable) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
text = stringResource(Res.string.import_configuration),
|
||||
leadingIcon = Icons.Rounded.Download,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
|
||||
onClick = onImport,
|
||||
)
|
||||
ListItem(
|
||||
text = stringResource(Res.string.export_configuration),
|
||||
leadingIcon = Icons.Rounded.Upload,
|
||||
enabled = enabled,
|
||||
onClick = onExport,
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.clean_node_database_title),
|
||||
leadingIcon = Icons.Rounded.CleaningServices,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.debug_panel),
|
||||
leadingIcon = Icons.Rounded.BugReport,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
|
||||
)
|
||||
}
|
||||
|
||||
ExpressiveSection(title = stringResource(Res.string.administration)) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.administration),
|
||||
leadingIcon = Icons.Rounded.AdminPanelSettings,
|
||||
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
trailingIconTint = MaterialTheme.colorScheme.error,
|
||||
enabled = enabled,
|
||||
) {
|
||||
onNavigate(SettingsRoutes.Administration)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLocal) {
|
||||
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
|
||||
if (isManaged) {
|
||||
ManagedMessage()
|
||||
}
|
||||
|
||||
if (isOtaCapable) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_update_title),
|
||||
leadingIcon = Icons.Rounded.SystemUpdate,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.clean_node_database_title),
|
||||
leadingIcon = Icons.Rounded.CleaningServices,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.debug_panel),
|
||||
leadingIcon = Icons.Rounded.BugReport,
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveSection(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
titleColor: Color = MaterialTheme.colorScheme.primary,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = titleColor,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,12 @@ constructor(
|
||||
.onEach { lc -> if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(radioConfig = lc) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
radioConfigRepository.channelSetFlow
|
||||
.onEach { cs ->
|
||||
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(channelList = cs.settings) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
radioConfigRepository.moduleConfigFlow
|
||||
.onEach { lmc ->
|
||||
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(moduleConfig = lmc) }
|
||||
@@ -608,16 +614,7 @@ constructor(
|
||||
fun setResponseStateLoading(route: Enum<*>) {
|
||||
val destNum = destNode.value?.num ?: return
|
||||
|
||||
_radioConfigState.update {
|
||||
RadioConfigState(
|
||||
isLocal = it.isLocal,
|
||||
connected = it.connected,
|
||||
route = route.name,
|
||||
metadata = it.metadata,
|
||||
nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
|
||||
responseState = ResponseState.Loading(),
|
||||
)
|
||||
}
|
||||
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
|
||||
|
||||
when (route) {
|
||||
ConfigRoute.USER -> getOwner(destNum)
|
||||
@@ -862,6 +859,14 @@ constructor(
|
||||
sendAdminRequest(destNum)
|
||||
}
|
||||
requestIds.update { it.apply { remove(data.request_id) } }
|
||||
|
||||
if (requestIds.value.isEmpty()) {
|
||||
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
|
||||
clearPacketResponse()
|
||||
} else if (route.isEmpty()) {
|
||||
setResponseStateSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,13 @@ import org.meshtastic.core.ui.component.dragContainer
|
||||
import org.meshtastic.core.ui.component.dragDropItemsIndexed
|
||||
import org.meshtastic.core.ui.component.rememberDragDropState
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
|
||||
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
|
||||
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
|
||||
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
|
||||
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
|
||||
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
|
||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config
|
||||
@@ -80,20 +82,24 @@ import org.meshtastic.proto.Config
|
||||
fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.responseState.isWaiting()) {
|
||||
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ChannelConfigScreen(
|
||||
title = stringResource(Res.string.channels),
|
||||
onBack = onBack,
|
||||
settingsList = state.channelList,
|
||||
loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
|
||||
maxChannels = viewModel.maxChannels,
|
||||
firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
|
||||
enabled = state.connected,
|
||||
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
|
||||
)
|
||||
|
||||
ChannelConfigScreen(
|
||||
title = stringResource(Res.string.channels),
|
||||
onBack = onBack,
|
||||
settingsList = state.channelList,
|
||||
loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
|
||||
maxChannels = viewModel.maxChannels,
|
||||
firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
|
||||
enabled = state.connected,
|
||||
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
|
||||
)
|
||||
LoadingOverlay(state = state.responseState)
|
||||
|
||||
if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
|
||||
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.radio.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
|
||||
private const val LOADING_OVERLAY_ALPHA = 0.8f
|
||||
private const val PERCENTAGE_FACTOR = 100
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(visible = state is ResponseState.Loading, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = LOADING_OVERLAY_ALPHA))
|
||||
.clickable(enabled = false) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
if (state is ResponseState.Loading) {
|
||||
val progress by
|
||||
animateFloatAsState(
|
||||
targetValue = state.completed.toFloat() / state.total.toFloat(),
|
||||
label = "loading_progress",
|
||||
)
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularWavyProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(80.dp),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
state.status?.let { status ->
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,17 @@ package org.meshtastic.feature.settings.radio.component
|
||||
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,19 +36,23 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.close
|
||||
import org.meshtastic.core.resources.delivery_confirmed
|
||||
import org.meshtastic.core.resources.delivery_confirmed_reboot_warning
|
||||
import org.meshtastic.core.resources.error
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.feature.settings.radio.ResponseState
|
||||
|
||||
private const val AUTO_DISMISS_DELAY_MS = 1500L
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
|
||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
@@ -49,54 +60,139 @@ fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit
|
||||
if (state is ResponseState.Success) {
|
||||
delay(AUTO_DISMISS_DELAY_MS)
|
||||
onDismiss()
|
||||
backDispatcher?.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = "", // Title is handled in the text block for more control
|
||||
onDismiss = if (state is ResponseState.Loading) onDismiss else null,
|
||||
title = null,
|
||||
icon = null,
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (state is ResponseState.Loading) {
|
||||
val progress by
|
||||
animateFloatAsState(
|
||||
targetValue = state.completed.toFloat() / state.total.toFloat(),
|
||||
label = "progress",
|
||||
)
|
||||
Text("%.0f%%".format(progress * 100))
|
||||
LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
state.status?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
when (state) {
|
||||
is ResponseState.Loading -> {
|
||||
LoadingContent(state = state, onComplete = onComplete)
|
||||
}
|
||||
if (state.completed >= state.total) onComplete()
|
||||
}
|
||||
if (state is ResponseState.Success) {
|
||||
Text(text = stringResource(Res.string.delivery_confirmed))
|
||||
}
|
||||
if (state is ResponseState.Error) {
|
||||
Text(text = stringResource(Res.string.error), minLines = 2)
|
||||
Text(text = state.error.asString())
|
||||
is ResponseState.Success -> {
|
||||
SuccessContent()
|
||||
}
|
||||
is ResponseState.Error -> {
|
||||
ErrorContent(state = state)
|
||||
}
|
||||
ResponseState.Empty -> {}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissable = false,
|
||||
onConfirm = {
|
||||
onDismiss()
|
||||
if (state is ResponseState.Success || state is ResponseState.Error) {
|
||||
onConfirm =
|
||||
if (state !is ResponseState.Loading) {
|
||||
{
|
||||
onDismiss()
|
||||
backDispatcher?.onBackPressed()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
confirmText = stringResource(Res.string.close),
|
||||
dismissText = null, // Hide dismiss button, only show "Close" confirm button
|
||||
dismissText = if (state is ResponseState.Loading) stringResource(Res.string.cancel) else null,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) {
|
||||
val progress by
|
||||
animateFloatAsState(targetValue = state.completed.toFloat() / state.total.toFloat(), label = "progress")
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "%.0f%%".format(progress * 100),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
state.status?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.completed >= state.total) onComplete()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessContent() {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(84.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.delivery_confirmed),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.delivery_confirmed_reboot_warning),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(state: ResponseState.Error) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(84.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.error),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = "${state.error.asString()}.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PacketResponseStateDialogPreview() {
|
||||
private fun PacketResponseStateDialogLoadingPreview() {
|
||||
PacketResponseStateDialog(state = ResponseState.Loading(total = 17, completed = 5))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PacketResponseStateDialogSuccessPreview() {
|
||||
PacketResponseStateDialog(state = ResponseState.Success(Unit))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PacketResponseStateDialogErrorPreview() {
|
||||
PacketResponseStateDialog(
|
||||
state = ResponseState.Error(org.meshtastic.core.resources.UiText.DynamicString("Failed to send packet")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -58,55 +59,58 @@ fun <T : Message<T, *>> RadioConfigScreenList(
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
if (responseState.isWaiting()) {
|
||||
PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
|
||||
}
|
||||
Box(modifier = modifier) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = title,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
val showFooterButtons = configState.isDirty || additionalDirtyCheck()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = title,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onBack,
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
val showFooterButtons = configState.isDirty || additionalDirtyCheck()
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
content()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
content()
|
||||
|
||||
item {
|
||||
AnimatedVisibility(
|
||||
visible = showFooterButtons,
|
||||
enter = fadeIn() + expandIn(),
|
||||
exit = fadeOut() + shrinkOut(),
|
||||
) {
|
||||
PreferenceFooter(
|
||||
enabled = enabled && showFooterButtons,
|
||||
negativeText = stringResource(Res.string.discard_changes),
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
configState.reset()
|
||||
onDiscard()
|
||||
},
|
||||
positiveText = stringResource(Res.string.save_changes),
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
onSave(configState.value)
|
||||
},
|
||||
)
|
||||
item {
|
||||
AnimatedVisibility(
|
||||
visible = showFooterButtons,
|
||||
enter = fadeIn() + expandIn(),
|
||||
exit = fadeOut() + shrinkOut(),
|
||||
) {
|
||||
PreferenceFooter(
|
||||
enabled = enabled && showFooterButtons,
|
||||
negativeText = stringResource(Res.string.discard_changes),
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
configState.reset()
|
||||
onDiscard()
|
||||
},
|
||||
positiveText = stringResource(Res.string.save_changes),
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
onSave(configState.value)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadingOverlay(state = responseState)
|
||||
|
||||
if (responseState is ResponseState.Success || responseState is ResponseState.Error) {
|
||||
PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user