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