From 077ac1e9db855578d525a651c359d33329f4b7fc Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 29 Apr 2026 09:02:56 -0500 Subject: [PATCH] fix: prompt to confirm discarding unsaved changes in config screens Fixes #5283 by introducing an UnsavedChangesDialog component and integrating it into RadioConfigScreenList and ChannelConfigScreen. Utilizes PlatformBackHandler to guard against accidental system back gestures. --- .../composeResources/values/strings.xml | 2 ++ .../core/ui/component/UnsavedChangesDialog.kt | 36 +++++++++++++++++++ .../radio/channel/ChannelConfigScreen.kt | 23 +++++++++++- .../radio/component/RadioConfigScreenList.kt | 34 ++++++++++++++++-- 4 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/UnsavedChangesDialog.kt diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index fe5f0b808..edfedf8b6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -182,6 +182,8 @@ Accept Cancel Discard + You have unsaved changes. Are you sure you want to discard them? + Stay Save New Channel URL received Report diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/UnsavedChangesDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/UnsavedChangesDialog.kt new file mode 100644 index 000000000..5da2f26f2 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/UnsavedChangesDialog.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.runtime.Composable +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.discard_changes +import org.meshtastic.core.resources.stay +import org.meshtastic.core.resources.unsaved_changes_message + +@Composable +fun UnsavedChangesDialog(onDiscard: () -> Unit, onStay: () -> Unit) { + MeshtasticDialog( + titleRes = Res.string.cancel, + messageRes = Res.string.unsaved_changes_message, + confirmTextRes = Res.string.discard_changes, + onConfirm = onDiscard, + dismissTextRes = Res.string.stay, + onDismiss = onStay, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 885e64219..4027222c5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -137,6 +137,27 @@ private fun ChannelConfigScreen( var showEditChannelDialog: Int? by rememberSaveable { mutableStateOf(null) } var showChannelLegendDialog by rememberSaveable { mutableStateOf(false) } + var showExitConfirmation by rememberSaveable { mutableStateOf(false) } + + val handleBack = { + if (isEditing) { + showExitConfirmation = true + } else { + onBack() + } + } + + org.meshtastic.core.ui.util.PlatformBackHandler(enabled = isEditing) { showExitConfirmation = true } + + if (showExitConfirmation) { + org.meshtastic.core.ui.component.UnsavedChangesDialog( + onDiscard = { + showExitConfirmation = false + onBack() + }, + onStay = { showExitConfirmation = false }, + ) + } if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return @@ -164,7 +185,7 @@ private fun ChannelConfigScreen( MainAppBar( title = title, canNavigateUp = true, - onNavigateUp = onBack, + onNavigateUp = handleBack, ourNode = null, showNodeChip = false, actions = {}, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index d1301002a..9c2652094 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt @@ -31,6 +31,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp @@ -41,6 +45,8 @@ import org.meshtastic.core.resources.discard_changes import org.meshtastic.core.resources.save_changes import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.PreferenceFooter +import org.meshtastic.core.ui.component.UnsavedChangesDialog +import org.meshtastic.core.ui.util.PlatformBackHandler import org.meshtastic.feature.settings.radio.ResponseState @Suppress("LongMethod") @@ -60,14 +66,38 @@ fun > RadioConfigScreenList( content: LazyListScope.() -> Unit, ) { val focusManager = LocalFocusManager.current + val isEditing = configState.isDirty || additionalDirtyCheck() + var showExitConfirmation by rememberSaveable { mutableStateOf(false) } + + val handleBack = { + if (isEditing) { + showExitConfirmation = true + } else { + onBack() + } + } + + PlatformBackHandler(enabled = isEditing) { showExitConfirmation = true } Box(modifier = modifier) { + if (showExitConfirmation) { + UnsavedChangesDialog( + onDiscard = { + showExitConfirmation = false + configState.reset() + onDiscard() + onBack() + }, + onStay = { showExitConfirmation = false }, + ) + } + Scaffold( topBar = { MainAppBar( title = title, canNavigateUp = true, - onNavigateUp = onBack, + onNavigateUp = handleBack, ourNode = null, showNodeChip = false, actions = actions, @@ -75,7 +105,7 @@ fun > RadioConfigScreenList( ) }, ) { innerPadding -> - val showFooterButtons = configState.isDirty || additionalDirtyCheck() + val showFooterButtons = isEditing LazyColumn( modifier = Modifier.padding(innerPadding).fillMaxSize(),