From cdfd0e3d5db53f1498be10fe87eae592be47c05e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 21 Jan 2026 06:36:15 -0600
Subject: [PATCH] fix(configs): Improve loading state feedback and dialog
behavior (#4271)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 2 +
.../settings/radio/RadioConfigViewModel.kt | 41 ++++++++++++++-----
.../feature/settings/radio/ResponseState.kt | 6 +--
.../component/PacketResponseStateDialog.kt | 23 +++++++++--
4 files changed, 56 insertions(+), 16 deletions(-)
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index ce83b8e20..8a0a4f4f8 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -758,6 +758,8 @@
User ID
Uptime
Load %1$d
+ Fetching Channel %1$d/%2$d
+ Fetching %1$s
Disk Free %1$d
Timestamp
Heading
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 5d66f1353..ba97b65d8 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
@@ -32,6 +32,7 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import com.google.protobuf.MessageLite
+import com.meshtastic.core.strings.getString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -64,6 +65,8 @@ import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cant_shutdown
+import org.meshtastic.core.strings.fetching_channel_indexed
+import org.meshtastic.core.strings.fetching_config
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
@@ -131,7 +134,7 @@ constructor(
val destNode: StateFlow
get() = _destNode
- private val requestIds = MutableStateFlow(hashSetOf())
+ private val requestIds = MutableStateFlow(emptySet())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow = _radioConfigState
@@ -213,11 +216,11 @@ constructor(
val packetId = service.packetId
try {
requestAction(service, packetId, destNum)
- requestIds.update { it.apply { add(packetId) } }
+ requestIds.update { it + packetId }
_radioConfigState.update { state ->
val currentState = state.responseState
if (currentState is ResponseState.Loading) {
- val total = requestIds.value.size.coerceAtLeast(1)
+ val total = maxOf(currentState.total, requestIds.value.size)
state.copy(responseState = currentState.copy(total = total))
} else {
state.copy(
@@ -553,13 +556,23 @@ constructor(
}
fun clearPacketResponse() {
- requestIds.value = hashSetOf()
+ requestIds.value = emptySet()
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
}
+ private fun getTitleForRoute(route: Enum<*>) = when (route) {
+ is ConfigRoute -> route.title
+ is ModuleRoute -> route.title
+ is AdminRoute -> route.title
+ else -> null
+ }
+
+ @Suppress("CyclomaticComplexMethod")
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
+ val title = getTitleForRoute(route)
+
_radioConfigState.update {
RadioConfigState(
isLocal = it.isLocal,
@@ -567,7 +580,10 @@ constructor(
route = route.name,
metadata = it.metadata,
nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
- responseState = ResponseState.Loading(),
+ responseState =
+ ResponseState.Loading(
+ status = title?.let { t -> getString(Res.string.fetching_config, getString(t)) },
+ ),
)
}
@@ -646,11 +662,14 @@ constructor(
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
- private fun incrementCompleted() {
+ private fun incrementCompleted(status: String? = null) {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val increment = state.responseState.completed + 1
- state.copy(responseState = state.responseState.copy(completed = increment))
+ state.copy(
+ responseState =
+ state.responseState.copy(completed = increment, status = status ?: state.responseState.status),
+ )
} else {
state // Return the unchanged state for other response states
}
@@ -671,7 +690,7 @@ constructor(
if (parsed.errorReason != MeshProtos.Routing.Error.NONE) {
sendError(getStringResFrom(parsed.errorReasonValue))
} else if (packet.from == destNum && route.isEmpty()) {
- requestIds.update { it.apply { remove(data.requestId) } }
+ requestIds.update { it - data.requestId }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
@@ -702,7 +721,9 @@ constructor(
state.channelList.toMutableList().apply { add(response.index, response.settings) },
)
}
- incrementCompleted()
+ incrementCompleted(
+ getString(Res.string.fetching_channel_indexed, response.index + 1, maxChannels),
+ )
if (response.index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, response.index + 1)
@@ -761,7 +782,7 @@ constructor(
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
}
- requestIds.update { it.apply { remove(data.requestId) } }
+ requestIds.update { it - data.requestId }
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt
index 8c6df952a..96e5e3df7 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/ResponseState.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.feature.settings.radio
import org.meshtastic.feature.settings.util.UiText
@@ -23,7 +22,8 @@ import org.meshtastic.feature.settings.util.UiText
sealed class ResponseState {
data object Empty : ResponseState()
- data class Loading(var total: Int = 1, var completed: Int = 0) : ResponseState()
+ data class Loading(var total: Int = 1, var completed: Int = 0, var status: String? = null) :
+ ResponseState()
data class Success(val result: T) : ResponseState()
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 647afcc84..11798ac1a 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
@@ -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.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
@@ -28,13 +27,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.strings.Res
import org.meshtastic.core.strings.close
@@ -42,9 +44,17 @@ import org.meshtastic.core.strings.delivery_confirmed
import org.meshtastic.core.strings.error
import org.meshtastic.feature.settings.radio.ResponseState
+private const val AUTO_DISMISS_DELAY_MS = 1500L
+
@Composable
fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
+ LaunchedEffect(state) {
+ if (state is ResponseState.Success) {
+ delay(AUTO_DISMISS_DELAY_MS)
+ onDismiss()
+ }
+ }
AlertDialog(
onDismissRequest = {},
shape = RoundedCornerShape(16.dp),
@@ -58,7 +68,14 @@ fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit
)
Text("%.0f%%".format(progress * 100))
LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
- if (state.total == state.completed) onComplete()
+ state.status?.let {
+ Text(
+ text = it,
+ modifier = Modifier.padding(top = 8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ if (state.completed >= state.total) onComplete()
}
if (state is ResponseState.Success) {
Text(text = stringResource(Res.string.delivery_confirmed))