Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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)

Expand All @@ -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),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>.asState(): StateFlow<Boolean> = stateIn(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean>,
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<Boolean>,
clear: suspend () -> Unit,
show: suspend () -> Unit,
) {
if (flag.first()) {
clear()
show()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading