fix(configs): Improve loading state feedback and dialog behavior (#4271)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-21 06:36:15 -06:00
committed by GitHub
parent 932c31c2b8
commit cdfd0e3d5d
4 changed files with 56 additions and 16 deletions

View File

@@ -758,6 +758,8 @@
<string name="user_id">User ID</string>
<string name="uptime">Uptime</string>
<string name="load_indexed">Load %1$d</string>
<string name="fetching_channel_indexed">Fetching Channel %1$d/%2$d</string>
<string name="fetching_config">Fetching %1$s</string>
<string name="disk_free_indexed">Disk Free %1$d</string>
<string name="timestamp">Timestamp</string>
<string name="heading">Heading</string>

View File

@@ -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<Node?>
get() = _destNode
private val requestIds = MutableStateFlow(hashSetOf<Int>())
private val requestIds = MutableStateFlow(emptySet<Int>())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _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 }
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import org.meshtastic.feature.settings.util.UiText
@@ -23,7 +22,8 @@ import org.meshtastic.feature.settings.util.UiText
sealed class ResponseState<out T> {
data object Empty : ResponseState<Nothing>()
data class Loading(var total: Int = 1, var completed: Int = 0) : ResponseState<Nothing>()
data class Loading(var total: Int = 1, var completed: Int = 0, var status: String? = null) :
ResponseState<Nothing>()
data class Success<T>(val result: T) : ResponseState<T>()

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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 <T> PacketResponseStateDialog(state: ResponseState<T>, 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 <T> PacketResponseStateDialog(state: ResponseState<T>, 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))