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() + } +}