fix: display snackbar notification on save failures (Closes #77) (#119)

This commit was merged in pull request #119.
This commit is contained in:
2026-04-04 21:40:16 +02:00
parent fd8306b386
commit 240b994900
4 changed files with 211 additions and 1 deletions
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -48,6 +49,7 @@ import org.shahondin1624.theme.WindowSizeClass
import org.shahondin1624.viewmodel.CharacterCreationViewModel
import org.shahondin1624.logging.AppLogger
import org.shahondin1624.viewmodel.CharacterViewModel
import org.shahondin1624.viewmodel.SaveStatus
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@@ -276,6 +278,19 @@ private fun MainScaffold(
val currentRoute = currentRoute(navController)
val character by characterViewModel.character.collectAsState()
val saveStatus by characterViewModel.saveStatus.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
// Show snackbar when a save error occurs
LaunchedEffect(saveStatus) {
if (saveStatus is SaveStatus.Error) {
val errorMessage = (saveStatus as SaveStatus.Error).message
snackbarHostState.showSnackbar(
message = "Save failed: $errorMessage",
actionLabel = "Dismiss",
duration = SnackbarDuration.Long
)
}
}
// Dice roll history (in-memory, not persisted)
val diceRollHistory = remember { DiceRollHistory() }
@@ -306,6 +321,12 @@ private fun MainScaffold(
Scaffold(
modifier = modifier.fillMaxSize(),
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR)
)
},
topBar = {
TopAppBar(
title = { Text(topBarTitle) },
@@ -135,6 +135,7 @@ object TestTags {
const val SAVE_STATUS_SAVING = "save_status_saving"
const val SAVE_STATUS_SAVED = "save_status_saved"
const val SAVE_STATUS_ERROR = "save_status_error"
const val SAVE_ERROR_SNACKBAR = "save_error_snackbar"
// --- Top app bar ---
const val TOP_APP_BAR = "top_app_bar"
@@ -94,7 +94,7 @@ fun SaveStatusIndicator(
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = "Save failed",
contentDescription = "Save failed: ${currentStatus.message}",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
@@ -0,0 +1,188 @@
package org.shahondin1624
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.charactermodel.SaveStatusIndicator
import org.shahondin1624.viewmodel.SaveStatus
import kotlin.test.Test
/**
* Tests for save error notification behavior.
* Verifies that save failures produce user-visible error feedback
* (issue #77: Silent save failures).
*/
@OptIn(ExperimentalTestApi::class)
class SaveErrorNotificationTest {
@Test
fun saveStatusIndicatorShowsErrorIcon() = runComposeUiTest {
setContent {
MaterialTheme {
SaveStatusIndicator(status = SaveStatus.Error("disk full"))
}
}
onNodeWithTag(TestTags.SAVE_STATUS_ERROR).assertIsDisplayed()
onNodeWithText("Save failed").assertIsDisplayed()
}
@Test
fun saveStatusIndicatorShowsNothingWhenIdle() = runComposeUiTest {
setContent {
MaterialTheme {
SaveStatusIndicator(status = SaveStatus.Idle)
}
}
onNodeWithTag(TestTags.SAVE_STATUS_SAVING).assertDoesNotExist()
onNodeWithTag(TestTags.SAVE_STATUS_SAVED).assertDoesNotExist()
onNodeWithTag(TestTags.SAVE_STATUS_ERROR).assertDoesNotExist()
}
@Test
fun saveStatusIndicatorShowsSavingSpinner() = runComposeUiTest {
setContent {
MaterialTheme {
SaveStatusIndicator(status = SaveStatus.Saving)
}
}
onNodeWithTag(TestTags.SAVE_STATUS_SAVING).assertIsDisplayed()
onNodeWithText("Saving...").assertIsDisplayed()
}
@Test
fun saveStatusIndicatorShowsSavedCheckmark() = runComposeUiTest {
setContent {
MaterialTheme {
SaveStatusIndicator(status = SaveStatus.Saved)
}
}
onNodeWithTag(TestTags.SAVE_STATUS_SAVED).assertIsDisplayed()
onNodeWithText("Saved").assertIsDisplayed()
}
@Test
fun snackbarAppearsOnSaveError() = runComposeUiTest {
setContent {
MaterialTheme {
val snackbarHostState = remember { SnackbarHostState() }
var saveStatus by remember { mutableStateOf<SaveStatus>(SaveStatus.Idle) }
LaunchedEffect(saveStatus) {
if (saveStatus is SaveStatus.Error) {
val errorMessage = (saveStatus as SaveStatus.Error).message
snackbarHostState.showSnackbar(
message = "Save failed: $errorMessage",
actionLabel = "Dismiss",
duration = SnackbarDuration.Long
)
}
}
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR)
)
}
) {
// Content area is empty for this test
}
// Trigger the error state
LaunchedEffect(Unit) {
saveStatus = SaveStatus.Error("serialization failed")
}
}
}
waitForIdle()
onNodeWithText("Save failed: serialization failed").assertIsDisplayed()
}
@Test
fun snackbarDoesNotAppearOnSuccessfulSave() = runComposeUiTest {
setContent {
MaterialTheme {
val snackbarHostState = remember { SnackbarHostState() }
val saveStatus = SaveStatus.Saved
LaunchedEffect(saveStatus) {
if (saveStatus is SaveStatus.Error) {
val errorMessage = (saveStatus as SaveStatus.Error).message
snackbarHostState.showSnackbar(
message = "Save failed: $errorMessage",
actionLabel = "Dismiss",
duration = SnackbarDuration.Long
)
}
}
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR)
)
}
) {
// Content area is empty for this test
}
}
}
waitForIdle()
onNodeWithText("Save failed", substring = true).assertDoesNotExist()
}
@Test
fun snackbarIncludesErrorDetails() = runComposeUiTest {
setContent {
MaterialTheme {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
snackbarHostState.showSnackbar(
message = "Save failed: Storage permission denied",
actionLabel = "Dismiss",
duration = SnackbarDuration.Long
)
}
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR)
)
}
) {
// Content area is empty for this test
}
}
}
waitForIdle()
onNodeWithText("Save failed: Storage permission denied").assertIsDisplayed()
onNodeWithText("Dismiss").assertIsDisplayed()
}
}