From 8aba3c2bcf5c6afbbf3b5f5b092ccc0031590e86 Mon Sep 17 00:00:00 2001 From: dadachi Date: Fri, 16 Oct 2026 04:23:54 +0900 Subject: [PATCH] Dismiss the previous error when a reload or sign-in starts After a failed load (e.g. offline, "[NATIVEAPPTEMPLATE-2002] ..."), Reload succeeded but the error snackbar stayed until dismissed: the view model kept the message, so the screen's SnackbarMessageEffect kept showing it. Each screen's load and each sign-in attempt now clears the message when it starts, which cancels the shown snackbar; an error of the new attempt still shows. Ported from MyTurnTagCreator-Android #139. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YDK5huvuRECyVrsQEdTN9q --- CHANGELOG.md | 1 + .../SignInEmailAndPasswordViewModel.kt | 2 + .../ui/common/ErrorShowingUiState.kt | 5 ++ .../ui/settings/SettingsViewModel.kt | 1 + .../ui/settings/ShopkeeperEditViewModel.kt | 1 + .../ui/shop_detail/ShopDetailViewModel.kt | 1 + .../ShopBasicSettingsViewModel.kt | 1 + .../ui/shop_settings/ShopSettingsViewModel.kt | 1 + .../item_tag_detail/ItemTagDetailViewModel.kt | 1 + .../item_tag_detail/ItemTagEditViewModel.kt | 1 + .../item_tag_list/ItemTagListViewModel.kt | 1 + .../ui/shops/ShopListViewModel.kt | 1 + .../testing/repository/CallFailures.kt | 8 +++ .../testing/repository/TestLoginRepository.kt | 4 +- .../SignInEmailAndPasswordViewModelTest.kt | 36 ++++++++++++ .../ui/common/SnackbarMessageEffectTest.kt | 55 +++++++++++++++++++ .../ui/shops/ShopListViewModelTest.kt | 31 +++++++++++ 17 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SnackbarMessageEffectTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d16060a..7d355d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Error messages are in the app language and say what the user can do, with the same wording as NativeAppTemplate-Free-iOS; the error codes are unchanged. A request that got no answer (no network, a timeout) or an error status without a message (e.g. a 502 page) shows `[NATIVEAPPTEMPLATE-2002]` "Something went wrong while communicating with the server. Please try again later." (サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。) instead of the English system text (e.g. "Processing error: Unable to resolve host ..." or "Processing error: HTTP 502"). A response the app could not process (`2002`) and `1001` show "An unexpected error occurred." (予期しないエラーが発生しました。). The server's own messages are shown as is, without the developer suffix " [Status: 422]" (`2001`) or the "Processing error: " prefix (sign-in's "Invalid login credentials"). The details are logged with the code instead (`adb logcat -s ApiError`) +- An error snackbar no longer stays after the screen recovers (e.g. the offline `[NATIVEAPPTEMPLATE-2002]` error still showing after the network is back and Reload succeeded). Every screen's load (Reload, pull to refresh, resume) and every sign-in attempt now clears the previous attempt's error when it starts, which closes its snackbar; an error of the new attempt still shows. Ported from MyTurnTagCreator-Android #139 ## [3.2.6] - 2026-05-24 diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt index ccb284e..354bd2d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt @@ -46,6 +46,8 @@ class SignInEmailAndPasswordViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + // A new attempt: the last one's error is stale (its snackbar closes). + message = "", ) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ErrorShowingUiState.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ErrorShowingUiState.kt index 0eddd1d..7bd213a 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ErrorShowingUiState.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/ErrorShowingUiState.kt @@ -10,6 +10,11 @@ import kotlinx.coroutines.flow.update * A screen's UI state that shows a failed load or save: the error in the snackbar, and the * spinner stopped. Implementing it once per state keeps every failure path of the screen the same, * so none of them can forget to stop the spinner. + * + * A new attempt (a load, a reload from the error view, pull to refresh or resume, a sign-in) sets + * the message back to "" when it starts: the last attempt's error is stale, and clearing it closes + * its snackbar (`SnackbarMessageEffect` cancels the shown one). The new attempt's own error still + * shows. */ interface ErrorShowingUiState> { /** This state showing [message], with loading stopped. */ diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModel.kt index cbe61fc..52f74f7 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/SettingsViewModel.kt @@ -45,6 +45,7 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, ) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt index 79111ce..0799b1b 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt @@ -64,6 +64,7 @@ class ShopkeeperEditViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, isUpdated = false, isEmailUpdated = false, diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt index 7452057..96a113a 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailViewModel.kt @@ -54,6 +54,7 @@ class ShopDetailViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, ) } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt index 9503355..be8ad51 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopBasicSettingsViewModel.kt @@ -62,6 +62,7 @@ class ShopBasicSettingsViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, isUpdated = false, ) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt index 861706a..d3acb63 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/ShopSettingsViewModel.kt @@ -53,6 +53,7 @@ class ShopSettingsViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, isShopDeleted = false, ) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt index c25457d..76011fe 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt @@ -55,6 +55,7 @@ class ItemTagDetailViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, isDeleted = false, ) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt index 6dd7913..a6ee39b 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagEditViewModel.kt @@ -54,6 +54,7 @@ class ItemTagEditViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, isUpdated = false, ) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt index 3a92a98..b37d78d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt @@ -87,6 +87,7 @@ class ItemTagListViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, loadMoreFailed = false, ) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt index d5d007a..420eecd 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModel.kt @@ -64,6 +64,7 @@ class ShopListViewModel @Inject constructor( _uiState.update { it.copy( isLoading = true, + message = "", success = false, ) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/CallFailures.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/CallFailures.kt index 967dbf4..f372b3b 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/CallFailures.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/CallFailures.kt @@ -25,6 +25,11 @@ class CallFailures { errors[call.name] = error } + /** Makes later [call]s succeed again, e.g. once the network is back. */ + fun succeed(call: KFunction<*>) { + errors.remove(call.name) + } + /** [success], or a flow that throws the error set for [call] by [fail]. */ fun orFail(call: KFunction<*>, success: Flow): Flow = errors[call.name]?.let { error -> flow { throw error } } ?: success } @@ -35,4 +40,7 @@ interface FailableRepository { /** Makes every later [call] fail with [error]. */ fun fail(call: KFunction<*>, error: Throwable) = failures.fail(call, error) + + /** Makes later [call]s succeed again. */ + fun succeed(call: KFunction<*>) = failures.succeed(call) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt index 17617a1..55ab52f 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -208,8 +208,8 @@ class TestLoginRepository : LoginRepository { permissionsFlow.tryEmit(permissions) } - /** Makes every later [login] call fail with [error]. */ - fun failLogin(error: Throwable) { + /** Makes every later [login] call fail with [error], or succeed again when null. */ + fun failLogin(error: Throwable?) { loginError = error } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt index b390a8c..e7b3fb3 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt @@ -12,6 +12,8 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginR import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.testing.util.TEST_API_ERROR_MESSAGE +import com.nativeapptemplate.nativeapptemplatefree.testing.util.testApiError import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first @@ -127,6 +129,40 @@ class SignInEmailAndPasswordViewModelTest { assertEquals(0, loginRepository.logoutCallCount) } + // Bug: a new sign-in attempt kept the previous attempt's error, so its snackbar stayed on + // screen after the next sign-in succeeded (as in MyTurnTagCreator-Android #139). + @Test + fun login_afterAFailedAttempt_clearsTheStaleError() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!) + viewModel.updatePassword(testInputPassword) + loginRepository.failLogin(testApiError) + viewModel.login() + assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message) + + loginRepository.failLogin(null) + loginRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper) + loginRepository.sendPermissions(testInputPermissions) + viewModel.login() + + // The successful attempt shows no error, so the old one must be gone. + assertEquals("", viewModel.uiState.value.message) + } + + @Test + fun login_whenAnotherAttemptFails_showsItsError() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!) + viewModel.updatePassword(testInputPassword) + loginRepository.failLogin(testApiError) + viewModel.login() + + viewModel.login() + + // Cleared when the attempt starts, then set again by its own failure. + assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message) + } + @Test fun login_whenTheCredentialsAreRejected_showsTheServerMessageAndSavesNothing() = runTest { backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SnackbarMessageEffectTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SnackbarMessageEffectTest.kt new file mode 100644 index 0000000..4cd0f7e --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SnackbarMessageEffectTest.kt @@ -0,0 +1,55 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SnackbarMessageEffectTest { + @get:Rule + val composeTestRule = createComposeRule() + + // A view model clears its message when a new attempt starts (ErrorShowingUiState). That must + // close the last attempt's snackbar, which stays until dismissed (Indefinite), and a failure of + // the new attempt must show again, even with the same text. + @Test + fun clearingTheMessage_closesItsSnackbar_andTheNextErrorShowsAgain() { + val host = SnackbarHostState() + var message by mutableStateOf("") + composeTestRule.setContent { + SnackbarMessageEffect( + message = message, + // As the app root passes it to the screens. + onShowSnackbar = { text, action, duration -> + host.showSnackbar(text, action, duration = duration) == SnackbarResult.ActionPerformed + }, + onMessageShown = { message = "" }, + ) + } + + composeTestRule.runOnIdle { message = OFFLINE_ERROR } + composeTestRule.waitForIdle() + assertEquals(OFFLINE_ERROR, host.currentSnackbarData?.visuals?.message) + + // Reload starts. + composeTestRule.runOnIdle { message = "" } + composeTestRule.waitForIdle() + assertNull(host.currentSnackbarData) + + // Reload fails again. + composeTestRule.runOnIdle { message = OFFLINE_ERROR } + composeTestRule.waitForIdle() + assertEquals(OFFLINE_ERROR, host.currentSnackbarData?.visuals?.message) + } +} + +private const val OFFLINE_ERROR = "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later." diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt index 025c15e..2776acc 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListViewModelTest.kt @@ -2,12 +2,15 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops import androidx.compose.runtime.getValue import androidx.compose.ui.test.junit4.v2.createComposeRule +import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.Shops import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import com.nativeapptemplate.nativeapptemplatefree.testing.util.TEST_API_ERROR_MESSAGE +import com.nativeapptemplate.nativeapptemplatefree.testing.util.testApiError import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -63,6 +66,34 @@ class ShopListViewModelTest { assertEquals(shopsFromRepository, uiStateValue.shops) } + // Bug: offline, the load failed; back online, Reload succeeded but the error snackbar stayed + // until dismissed. A new attempt clears the stale error (as in MyTurnTagCreator-Android #139). + @Test + fun reload_afterAFailedLoad_clearsTheStaleError() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + shopRepository.fail(ShopRepository::getShops, testApiError) + viewModel.reload() + assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message) + + shopRepository.succeed(ShopRepository::getShops) + shopRepository.sendShops(testInputShops) + viewModel.reload() + + assertTrue(viewModel.uiState.value.success) + assertEquals("", viewModel.uiState.value.message) + } + + @Test + fun reload_whenItFailsAgain_showsTheNewError() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + shopRepository.fail(ShopRepository::getShops, testApiError) + viewModel.reload() + + viewModel.reload() + + assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message) + } + @Test fun reload_replacesThePreviousCollectionInsteadOfAddingOne() = runTest { viewModel.reload()