From c6d6523fa175398ead3422a2d24fb6a11b409303 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 18:01:56 +0900 Subject: [PATCH] Show stored snackbar notices once, not again after a relaunch The app root showed the notices stored in DataStore (shop deleted, email changed, account deleted) in a LaunchedEffect keyed on the flag, and cleared the flag only after the Indefinite snackbar was dismissed. Closing the app while the snackbar was up left the flag set, so "Shop removed." came back at every launch. The flag is now cleared as soon as the notice is taken for display, in a collected effect so clearing it does not cancel the snackbar. The shop-deleted notice, stored just before the app restarts itself, is read once at launch: the process that stored it would otherwise take the flag and lose the snackbar a moment later when it exits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../ui/app_root/NativeAppTemplateApp.kt | 57 ++++++----- .../ui/app_root/NativeAppTemplateAppState.kt | 6 -- .../ui/app_root/StoredNotice.kt | 43 +++++++++ .../app_root/NativeAppTemplateAppStateTest.kt | 16 ---- .../ui/app_root/StoredNoticeTest.kt | 94 +++++++++++++++++++ 5 files changed, 165 insertions(+), 51 deletions(-) create mode 100644 app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt index 2e1f3d0..533090a 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateApp.kt @@ -1,5 +1,6 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.app_root +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -26,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -40,6 +42,7 @@ import com.nativeapptemplate.nativeapptemplatefree.designsystem.component.Native import com.nativeapptemplate.nativeapptemplatefree.designsystem.component.NativeAppTemplateNavigationBarItem import com.nativeapptemplate.nativeapptemplatefree.navigation.NativeAppTemplateNavHost import com.nativeapptemplate.nativeapptemplatefree.navigation.TopLevelDestination +import kotlinx.coroutines.launch import kotlin.reflect.KClass @OptIn( @@ -51,9 +54,6 @@ fun NativeAppTemplateApp(appState: NativeAppTemplateAppState) { val snackbarHostState = remember { SnackbarHostState() } val isOffline by appState.isOffline.collectAsStateWithLifecycle() - val isEmailUpdated by appState.isEmailUpdated.collectAsStateWithLifecycle() - val isMyAccountDeleted by appState.isMyAccountDeleted.collectAsStateWithLifecycle() - val isShopDeleted by appState.isShopDeleted.collectAsStateWithLifecycle() val dismissLabel = stringResource(R.string.dismiss) @@ -68,42 +68,41 @@ fun NativeAppTemplateApp(appState: NativeAppTemplateAppState) { } } - val reconfirmMessage = stringResource(R.string.reconfirm_description) - LaunchedEffect(isEmailUpdated) { - if (isEmailUpdated) { + // Notices stored in DataStore by the screen that caused them (see StoredNotice.kt). + val resources = LocalResources.current + LaunchedEffect(appState) { + val loginRepository = appState.loginRepository + fun showNotice(@StringRes messageId: Int): suspend () -> Unit = { snackbarHostState.showSnackbar( - message = reconfirmMessage, + message = resources.getString(messageId), actionLabel = dismissLabel, duration = Indefinite, ) - - appState.loginRepository.setIsEmailUpdated(false) } - } - val myAccountDeletedMessage = stringResource(R.string.message_shopkeeper_deleted) - LaunchedEffect(isMyAccountDeleted) { - if (isMyAccountDeleted) { - snackbarHostState.showSnackbar( - message = myAccountDeletedMessage, - actionLabel = dismissLabel, - duration = Indefinite, + // Stored when the shopkeeper is signed out; the app goes on in the same process. + launch { + showStoredNotice( + flag = loginRepository.isEmailUpdated(), + clear = { loginRepository.setIsEmailUpdated(false) }, + show = showNotice(R.string.reconfirm_description), ) - - appState.loginRepository.setIsMyAccountDeleted(false) } - } - - val shopDeletedMessage = stringResource(R.string.message_shop_deleted) - LaunchedEffect(isShopDeleted) { - if (isShopDeleted) { - snackbarHostState.showSnackbar( - message = shopDeletedMessage, - actionLabel = dismissLabel, - duration = Indefinite, + launch { + showStoredNotice( + flag = loginRepository.isMyAccountDeleted(), + clear = { loginRepository.setIsMyAccountDeleted(false) }, + show = showNotice(R.string.message_shopkeeper_deleted), ) + } - appState.loginRepository.setIsShopDeleted(false) + // Stored just before the app restarts itself; shown by the restarted app. + launch { + showNoticeStoredForLaunch( + flag = loginRepository.isShopDeleted(), + clear = { loginRepository.setIsShopDeleted(false) }, + show = showNotice(R.string.message_shop_deleted), + ) } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt index 2fcc11f..f0ec3ab 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt @@ -81,12 +81,6 @@ class NativeAppTemplateAppState( /** The tabs show only on the signed-in screens, not while a required update is pending. */ val shouldShowBottomBar = authDestination.map { it == AuthDestination.SHOPS }.asState() - val isEmailUpdated = loginRepository.isEmailUpdated().asState() - - val isMyAccountDeleted = loginRepository.isMyAccountDeleted().asState() - - val isShopDeleted = loginRepository.isShopDeleted().asState() - val isOffline = networkMonitor.isOnline.map(Boolean::not).asState() private fun Flow.asState(): StateFlow = stateIn( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt new file mode 100644 index 0000000..32cb416 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNotice.kt @@ -0,0 +1,43 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +// Notices stored as flags in DataStore by the screen that caused them. Each is cleared as soon as it +// is taken for display, not after the snackbar is dismissed: an Indefinite snackbar returns only +// when dismissed, and the app can be closed while it is up, which would leave the flag set and show +// the notice again at the next launch. Not keyed on the flag in a LaunchedEffect, so that clearing +// the flag does not cancel the snackbar. + +/** + * Shows the notice once each time [flag] turns true while the app runs: for notices stored when the + * shopkeeper is signed out (email changed, account deleted), which the same process shows. + */ +internal suspend fun showStoredNotice( + flag: Flow, + clear: suspend () -> Unit, + show: suspend () -> Unit, +) { + flag.filter { it }.collect { + clear() + show() + } +} + +/** + * Shows the notice if [flag] is set when the app starts: for notices stored just before the app + * restarts itself (shop deleted). The process that stored the + * flag must not take it: its snackbar would be gone a moment later with the process, and the + * restarted app would find the flag cleared. + */ +internal suspend fun showNoticeStoredForLaunch( + flag: Flow, + clear: suspend () -> Unit, + show: suspend () -> Unit, +) { + if (flag.first()) { + clear() + show() + } +} diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt index 2ccb390..2b49052 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppStateTest.kt @@ -105,22 +105,6 @@ class NativeAppTemplateAppStateTest { isOnline.value = false assertTrue(appState.isOffline.value) } - - @Test - fun deletionFlags_comeFromThePreferences() = testScope.runTest { - val appState = appState(backgroundScope) - backgroundScope.launch { appState.isShopDeleted.collect() } - backgroundScope.launch { appState.isMyAccountDeleted.collect() } - backgroundScope.launch { appState.isEmailUpdated.collect() } - - dataSource.setIsShopDeleted(true) - dataSource.setIsMyAccountDeleted(true) - dataSource.setIsEmailUpdated(true) - - assertTrue(appState.isShopDeleted.value) - assertTrue(appState.isMyAccountDeleted.value) - assertTrue(appState.isEmailUpdated.value) - } } private val signedInShopkeeper = LoggedInShopkeeper( diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt new file mode 100644 index 0000000..be753e5 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/StoredNoticeTest.kt @@ -0,0 +1,94 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.app_root + +import com.nativeapptemplate.nativeapptemplatefree.UserPreferences +import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePreferencesDataSource +import com.nativeapptemplate.nativeapptemplatefree.datastoreTest.InMemoryDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class StoredNoticeTest { + // Outlives the "launches" below, as DataStore's file outlives the app process. + private val preferences = NativeAppTemplatePreferencesDataSource( + InMemoryDataStore(UserPreferences.getDefaultInstance()), + ) + + private var shopDeletedShownCount = 0 + private var emailUpdatedShownCount = 0 + + /** An Indefinite snackbar: showSnackbar returns only when the shopkeeper dismisses it. */ + private suspend fun snackbar(isDismissed: Boolean) { + if (!isDismissed) awaitCancellation() + } + + /** One app process, showing the notices as NativeAppTemplateApp does; cancelled = process ended. */ + private fun CoroutineScope.launchApp(isDismissed: Boolean = false) = launch { + launch { + showNoticeStoredForLaunch( + flag = preferences.isShopDeleted(), + clear = { preferences.setIsShopDeleted(false) }, + ) { + shopDeletedShownCount++ + snackbar(isDismissed) + } + } + launch { + showStoredNotice( + flag = preferences.isEmailUpdated(), + clear = { preferences.setIsEmailUpdated(false) }, + ) { + emailUpdatedShownCount++ + snackbar(isDismissed) + } + } + } + + // Regression: the flag was cleared only after the snackbar was dismissed, so closing the app while + // it showed made it show again at every launch. + @Test + fun shopDeleted_isShownOnceByTheRestartedApp_evenIfItIsClosedWhileTheNoticeShows() = runTest(UnconfinedTestDispatcher()) { + val beforeTheDeletion = launchApp() + // ShopSettingsViewModel.deleteShop stores the flag, then the app restarts itself. + preferences.setIsShopDeleted(true) + beforeTheDeletion.cancel() + // Not by the process that is about to exit, whose snackbar would vanish with it. + assertEquals(0, shopDeletedShownCount) + + // The restarted app shows it, and is closed while the snackbar is still up. + launchApp().cancel() + launchApp().cancel() + + // Shown by the first launch after the deletion only. + assertEquals(1, shopDeletedShownCount) + } + + @Test + fun emailUpdated_isShownOnceByTheRunningApp_evenIfItIsClosedWhileTheNoticeShows() = runTest(UnconfinedTestDispatcher()) { + val app = launchApp() + // Changing the email signs out and stores the flag; the app does not restart. + preferences.setIsEmailUpdated(true) + assertEquals(1, emailUpdatedShownCount) + + // Closed while the snackbar is still up. + app.cancel() + launchApp().cancel() + + assertEquals(1, emailUpdatedShownCount) + } + + @Test + fun emailUpdated_storedAgain_isShownAgain() = runTest(UnconfinedTestDispatcher()) { + val app = launchApp(isDismissed = true) + + // Two sign-outs in the same process, each notice dismissed. + preferences.setIsEmailUpdated(true) + preferences.setIsEmailUpdated(true) + + assertEquals(2, emailUpdatedShownCount) + app.cancel() + } +}