Update General Error Handling Implementation

Include OFFLINE message in list of messages, with precedence over other messages. Pass offline value to state for use in other purposes.

Include action handling for success/failure to click snackbar button. Implementation of Bookmark UNDO function.

Moved implementations of onShowSnackbar and errorHandler closer to use case, removed unnecessary passed down parameters.
pull/1461/head
TM 1 year ago
parent e776036f7d
commit d25152b8d1

@ -76,7 +76,6 @@ class NiaAppStateTest {
NiaAppState(
navController = navController,
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
@ -99,7 +98,6 @@ class NiaAppStateTest {
fun niaAppState_destinations() = runTest {
composeTestRule.setContent {
state = rememberNiaAppState(
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
@ -118,7 +116,6 @@ class NiaAppStateTest {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
@ -129,7 +126,7 @@ class NiaAppStateTest {
networkMonitor.setConnected(false)
assertEquals(
true,
state.isOffline.value,
state.isOfflineState.value,
)
}
@ -139,7 +136,6 @@ class NiaAppStateTest {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
@ -160,14 +156,13 @@ class NiaAppStateTest {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
val id = state.addErrorMessage("Test Error Message 1")
val id = state.addShortErrorMessage("Test Error Message 1")
backgroundScope.launch { state.snackbarMessage.collect() }

@ -136,7 +136,6 @@ class MainActivity : ComponentActivity() {
}
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,

@ -38,12 +38,11 @@ import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetai
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
errorHandler: (String) -> Unit,
modifier: Modifier = Modifier,
startDestination: String = FOR_YOU_ROUTE,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = startDestination,
@ -52,13 +51,13 @@ fun NiaNavHost(
forYouScreen(onTopicClick = navController::navigateToInterests)
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
onShowSnackbar = { message, label, actionSuccess, actionFailure -> appState.addLongErrorMessage(error = message, label = label, successAction = actionSuccess, failureAction = actionFailure) },
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
errorHandler = errorHandler,
errorHandler = { message -> appState.addShortErrorMessage(message) },
)
interestsListDetailScreen()
}

@ -30,6 +30,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost
@ -42,6 +43,7 @@ import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -62,6 +64,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.util.ErrorMessage
import com.google.samples.apps.nowinandroid.core.data.util.MessageDuration
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationSuiteScaffold
@ -95,32 +99,26 @@ fun NiaApp(
) {
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
val offlineMessage = stringResource(R.string.not_connected)
SideEffect {
appState.offlineMessage = offlineMessage
}
val snackbarMessage by appState.snackbarMessage.collectAsStateWithLifecycle()
// If user is not connected to the internet show a snack bar to inform them.
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}
LaunchedEffect(snackbarMessage) {
snackbarMessage?.let {
val snackBarResult = snackbarHostState.showSnackbar(
message = it.message,
actionLabel = "Continue",
duration = Indefinite,
actionLabel = it.label,
duration = snackbarDurationOf(it.duration),
) == ActionPerformed
if (snackBarResult) {
handleSnackbarResult(snackBarResult, it)
// Remove Message from Queue
appState.clearErrorMessage(it.id)
}
}
}
NiaApp(
appState = appState,
@ -243,14 +241,6 @@ internal fun NiaApp(
) {
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
errorHandler = { message -> appState.addErrorMessage(message) },
)
}
@ -284,3 +274,20 @@ private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLev
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
} ?: false
private fun snackbarDurationOf(duration: MessageDuration?): SnackbarDuration {
return when (duration) {
MessageDuration.Short -> SnackbarDuration.Short
MessageDuration.Long -> SnackbarDuration.Long
MessageDuration.Indefinite -> SnackbarDuration.Indefinite
else -> SnackbarDuration.Short
}
}
private fun handleSnackbarResult(snackBarResult: Boolean, message: ErrorMessage) {
if (snackBarResult) {
message.actionPerformed?.invoke()
} else {
message.actionNotPerformed?.invoke()
}
}

@ -29,8 +29,8 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.ErrorMessage
import com.google.samples.apps.nowinandroid.core.data.util.ErrorMonitor
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
@ -48,13 +48,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
@Composable
fun rememberNiaAppState(
networkMonitor: NetworkMonitor,
errorMonitor: ErrorMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
@ -65,7 +63,6 @@ fun rememberNiaAppState(
return remember(
navController,
coroutineScope,
networkMonitor,
errorMonitor,
userNewsResourceRepository,
timeZoneMonitor,
@ -73,7 +70,6 @@ fun rememberNiaAppState(
NiaAppState(
navController = navController,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
@ -85,7 +81,6 @@ fun rememberNiaAppState(
class NiaAppState(
val navController: NavHostController,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
errorMonitor: ErrorMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
@ -102,17 +97,13 @@ class NiaAppState(
else -> null
}
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
val isOfflineState: StateFlow<Boolean> = isOffline.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
val snackbarMessage = errorMessages
.map { it.firstOrNull() }
.stateIn(
val snackbarMessage: StateFlow<ErrorMessage?> = errorMessage.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,

@ -138,7 +138,6 @@ class NiaAppScreenSizesScreenshotTests {
) {
NiaTheme {
val fakeAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,

@ -218,7 +218,6 @@ class SnackbarScreenshotTests {
BoxWithConstraints {
NiaTheme {
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
errorMonitor = errorMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,

@ -22,7 +22,11 @@ import kotlinx.coroutines.flow.Flow
* Interface for handling error messages.
*/
interface ErrorMonitor {
fun addErrorMessage(error: String): String?
fun addShortErrorMessage(error: String, label: String? = null, successAction: (() -> Unit)? = null, failureAction: (() -> Unit)? = null): String?
fun addLongErrorMessage(error: String, label: String? = null, successAction: (() -> Unit)? = null, failureAction: (() -> Unit)? = null): String?
fun addIndefiniteErrorMessage(error: String, label: String? = null, successAction: (() -> Unit)? = null, failureAction: (() -> Unit)? = null): String?
fun clearErrorMessage(id: String)
val errorMessages: Flow<List<ErrorMessage?>>
val errorMessage: Flow<ErrorMessage?>
val isOffline: Flow<Boolean>
var offlineMessage: String?
}

@ -18,6 +18,8 @@ package com.google.samples.apps.nowinandroid.core.data.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import java.util.UUID
import javax.inject.Inject
@ -26,12 +28,25 @@ import javax.inject.Inject
* Interface implementation for handling general errors.
*/
class SnackbarErrorMonitor @Inject constructor() : ErrorMonitor {
class SnackbarErrorMonitor @Inject constructor(val networkMonitor: NetworkMonitor) : ErrorMonitor {
/**
* List of [ErrorMessage] to be shown to the user, via Snackbar.
*/
private val _errorMessages = MutableStateFlow<List<ErrorMessage>>(emptyList())
override val errorMessages: Flow<List<ErrorMessage>> = _errorMessages
private val errorMessages = MutableStateFlow<List<ErrorMessage>>(emptyList())
override val isOffline = networkMonitor.isOnline
.map(Boolean::not)
override var offlineMessage: String? = null
override val errorMessage: Flow<ErrorMessage?> = combine(errorMessages, isOffline) { messages, isOffline ->
// Offline Error Message takes precedence over other messages
if (isOffline) {
ErrorMessage(offlineMessage ?: "You are offline", duration = MessageDuration.Indefinite)
} else {
messages.firstOrNull()
}
}
/**
* Creates an [ErrorMessage] from String value and adds it to the list.
@ -41,20 +56,32 @@ class SnackbarErrorMonitor @Inject constructor() : ErrorMonitor {
* Returns the ID of the new [ErrorMessage] if success
* Returns null if [error] is Blank
*/
override fun addErrorMessage(error: String): String? {
private fun addErrorMessage(error: String, label: String?, duration: MessageDuration?, actionPerformed: (() -> Unit)?, actionNotPerformed: (() -> Unit)?): String? {
if (error.isNotBlank()) {
val newError = ErrorMessage(error)
_errorMessages.update { it + newError }
val newError = ErrorMessage(error, label = label, duration = duration, actionPerformed = actionPerformed, actionNotPerformed = actionNotPerformed)
errorMessages.update { it + newError }
return newError.id
}
return null
}
override fun addShortErrorMessage(error: String, label: String?, successAction: (() -> Unit)?, failureAction: (() -> Unit)?): String? {
return addErrorMessage(error, label, MessageDuration.Short, successAction, failureAction)
}
override fun addLongErrorMessage(error: String, label: String?, successAction: (() -> Unit)?, failureAction: (() -> Unit)?): String? {
return addErrorMessage(error, label, MessageDuration.Long, successAction, failureAction)
}
override fun addIndefiniteErrorMessage(error: String, label: String?, successAction: (() -> Unit)?, failureAction: (() -> Unit)?): String? {
return addErrorMessage(error, label, MessageDuration.Indefinite, successAction, failureAction)
}
/**
* Removes the [ErrorMessage] with the specified [id] from the list.
*/
override fun clearErrorMessage(id: String) {
_errorMessages.update { it.filter { item -> item.id != id } }
errorMessages.update { it.filter { item -> item.id != id } }
}
}
@ -64,4 +91,14 @@ class SnackbarErrorMonitor @Inject constructor() : ErrorMonitor {
data class ErrorMessage(
val message: String,
val id: String = UUID.randomUUID().toString(),
val label: String? = null,
val duration: MessageDuration? = MessageDuration.Short,
val actionPerformed: (() -> Unit)? = null,
val actionNotPerformed: (() -> Unit)? = null,
)
enum class MessageDuration {
Short,
Long,
Indefinite,
}

@ -55,7 +55,7 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Loading,
onShowSnackbar = { _, _ -> false },
onShowSnackbar = { _, _, _, _ -> Unit },
removeFromBookmarks = {},
onTopicClick = {},
onNewsResourceViewed = {},
@ -76,7 +76,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(
userNewsResourcesTestData.take(2),
),
onShowSnackbar = { _, _ -> false },
onShowSnackbar = { _, _, _, _ -> Unit },
removeFromBookmarks = {},
onTopicClick = {},
onNewsResourceViewed = {},
@ -117,7 +117,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(
userNewsResourcesTestData.take(2),
),
onShowSnackbar = { _, _ -> false },
onShowSnackbar = { _, _, _, _ -> Unit },
removeFromBookmarks = { newsResourceId ->
assertEquals(userNewsResourcesTestData[0].id, newsResourceId)
removeFromBookmarksCalled = true
@ -152,7 +152,7 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
onShowSnackbar = { _, _ -> false },
onShowSnackbar = { _, _, _, _ -> Unit },
removeFromBookmarks = {},
onTopicClick = {},
onNewsResourceViewed = {},
@ -181,7 +181,7 @@ class BookmarksScreenTest {
CompositionLocalProvider(LocalLifecycleOwner provides testLifecycleOwner) {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
onShowSnackbar = { _, _ -> false },
onShowSnackbar = { _, _, _, _ -> Unit },
removeFromBookmarks = {},
onTopicClick = {},
onNewsResourceViewed = {},

@ -78,7 +78,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable
internal fun BookmarksRoute(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
onShowSnackbar: (String, String?, (() -> Unit)?, (() -> Unit)?) -> Unit,
modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel(),
) {
@ -103,7 +103,7 @@ internal fun BookmarksRoute(
@Composable
internal fun BookmarksScreen(
feedState: NewsFeedUiState,
onShowSnackbar: suspend (String, String?) -> Boolean,
onShowSnackbar: (String, String?, (() -> Unit)?, (() -> Unit)?) -> Unit,
removeFromBookmarks: (String) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit,
@ -117,12 +117,7 @@ internal fun BookmarksScreen(
LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) {
val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText)
if (snackBarResult) {
undoBookmarkRemoval()
} else {
clearUndoState()
}
onShowSnackbar(bookmarkRemovedMessage, undoText, { undoBookmarkRemoval() }, { clearUndoState() })
}
}

@ -28,7 +28,7 @@ fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMAR
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
onShowSnackbar: (String, String?, (() -> Unit)?, (() -> Unit)?) -> Unit,
) {
composable(route = BOOKMARKS_ROUTE) {
BookmarksRoute(onTopicClick, onShowSnackbar)

Loading…
Cancel
Save