diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index ecc23d80e..17e1db468 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -37,6 +37,7 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +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.designsystem.theme.NiaTheme @@ -64,6 +65,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var errorMonitor: ErrorMonitor + @Inject lateinit var timeZoneMonitor: TimeZoneMonitor @@ -135,6 +139,7 @@ class MainActivity : ComponentActivity() { setContent { val appState = rememberNiaAppState( networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index b237684ef..e4c7ca319 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.ui +import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -32,6 +33,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 @@ -54,6 +56,7 @@ import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -71,6 +74,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.MESSAGE +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.OFFLINE +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.UNKNOWN import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @@ -83,6 +90,8 @@ fun NiaApp( modifier: Modifier = Modifier, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { + val context = LocalContext.current + val shouldShowGradientBackground = appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU var showSettingsDialog by rememberSaveable { mutableStateOf(false) } @@ -97,16 +106,30 @@ fun NiaApp( ) { val snackbarHostState = remember { SnackbarHostState() } - val isOffline by appState.isOffline.collectAsStateWithLifecycle() + val stateMessage by appState.stateMessage.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(stateMessage) { + stateMessage?.let { message -> + + // Text and Duration values dictated by the UI + val (text, duration) = getSnackbarValues(context, message) + + // Determine whether user clicked action button + val snackBarResult = snackbarHostState.showSnackbar( + message = text, + actionLabel = message.label, + duration = duration, + ) == ActionPerformed + + // Handle result action + if (snackBarResult) { + message.onConfirm?.invoke() + } else { + message.onDelay?.invoke() + } + + // Remove Message from List + appState.errorMonitor.clearMessage(message) } } @@ -281,3 +304,11 @@ private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = this?.hierarchy?.any { it.hasRoute(route) } ?: false + +private fun getSnackbarValues(context: Context, message: MessageData): Pair { + return when (message.type) { + OFFLINE -> context.getString(R.string.not_connected) to SnackbarDuration.Indefinite + is MESSAGE -> (message.type as MESSAGE).value to SnackbarDuration.Long + UNKNOWN -> context.getString(R.string.unknown_error) to SnackbarDuration.Short + } +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 7c892c854..215431ff2 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -31,8 +31,11 @@ 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.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.model.data.MessageData +import com.google.samples.apps.nowinandroid.core.model.data.MessageType import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou @@ -53,6 +56,7 @@ import kotlinx.datetime.TimeZone @Composable fun rememberNiaAppState( networkMonitor: NetworkMonitor, + errorMonitor: ErrorMonitor, userNewsResourceRepository: UserNewsResourceRepository, timeZoneMonitor: TimeZoneMonitor, coroutineScope: CoroutineScope = rememberCoroutineScope(), @@ -63,6 +67,7 @@ fun rememberNiaAppState( navController, coroutineScope, networkMonitor, + errorMonitor, userNewsResourceRepository, timeZoneMonitor, ) { @@ -70,6 +75,7 @@ fun rememberNiaAppState( navController = navController, coroutineScope = coroutineScope, networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) @@ -81,6 +87,7 @@ class NiaAppState( val navController: NavHostController, coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, + val errorMonitor: ErrorMonitor, userNewsResourceRepository: UserNewsResourceRepository, timeZoneMonitor: TimeZoneMonitor, ) { @@ -115,6 +122,27 @@ class NiaAppState( initialValue = false, ) + private val errorMessages: StateFlow> = errorMonitor.messages.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + val stateMessage: StateFlow = combine(isOffline, errorMessages) { offline, errors -> + if (offline) { + // Priority is given to Offline Error Message over other types + MessageData(type = MessageType.OFFLINE) + } + // Otherwise, Display first from error monitor list if exists + else { + errors.firstOrNull() + } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null, + ) + /** * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the * route. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd92f3977..2f8cbe60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,4 +17,5 @@ Now in Android ⚠️ You aren’t connected to the internet + Unknown Error diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index 9c9488fde..47bd1baf3 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -32,6 +32,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +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.designsystem.theme.NiaTheme @@ -80,6 +81,9 @@ class NiaAppScreenSizesScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var errorMonitor: ErrorMonitor + @Inject lateinit var timeZoneMonitor: TimeZoneMonitor @@ -123,6 +127,7 @@ class NiaAppScreenSizesScreenshotTests { NiaTheme { val fakeAppState = rememberNiaAppState( networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index c6ddb54fb..97132aa72 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -29,6 +29,7 @@ import androidx.navigation.testing.TestNavHostController import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.util.TestErrorMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor import dagger.hilt.android.testing.HiltAndroidTest @@ -60,6 +61,8 @@ class NiaAppStateTest { // Create the test dependencies. private val networkMonitor = TestNetworkMonitor() + private var errorMonitor = TestErrorMonitor() + private val timeZoneMonitor = TestTimeZoneMonitor() private val userNewsResourceRepository = @@ -79,6 +82,7 @@ class NiaAppStateTest { navController = navController, coroutineScope = backgroundScope, networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) @@ -101,6 +105,7 @@ class NiaAppStateTest { composeTestRule.setContent { state = rememberNiaAppState( networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) @@ -119,6 +124,7 @@ class NiaAppStateTest { navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) @@ -139,6 +145,7 @@ class NiaAppStateTest { navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt index 78f568e03..e464eaffe 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt @@ -63,6 +63,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +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.designsystem.theme.NiaTheme @@ -113,6 +114,9 @@ class SnackbarInsetsScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var errorMonitor: ErrorMonitor + @Inject lateinit var timeZoneMonitor: TimeZoneMonitor @@ -254,6 +258,7 @@ class SnackbarInsetsScreenshotTests { NiaTheme { val appState = rememberNiaAppState( networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt index b9b1047c1..284ccc90d 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt @@ -36,6 +36,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +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.designsystem.theme.NiaTheme @@ -86,6 +87,9 @@ class SnackbarScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var errorMonitor: ErrorMonitor + @Inject lateinit var timeZoneMonitor: TimeZoneMonitor @@ -203,6 +207,7 @@ class SnackbarScreenshotTests { NiaTheme { val appState = rememberNiaAppState( networkMonitor = networkMonitor, + errorMonitor = errorMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/EmptyErrorMonitor.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/EmptyErrorMonitor.kt new file mode 100644 index 000000000..5eee73d69 --- /dev/null +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/EmptyErrorMonitor.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.test + +import com.google.samples.apps.nowinandroid.core.data.util.ErrorMonitor +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class EmptyErrorMonitor @Inject constructor() : ErrorMonitor { + + override fun addMessageByString(message: String): MessageData { + TODO("Not yet implemented") + } + + override fun addMessageByData(message: MessageData) { + TODO("Not yet implemented") + } + + override fun clearMessage(message: MessageData) { + TODO("Not yet implemented") + } + + override fun clearAllMessages() { + TODO("Not yet implemented") + } + + override val messages: Flow> + get() = flowOf(emptyList()) +} diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index 46158479c..4d48123bc 100644 --- a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecent import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository +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 dagger.Binds @@ -70,6 +71,11 @@ internal interface TestDataModule { networkMonitor: AlwaysOnlineNetworkMonitor, ): NetworkMonitor + @Binds + fun bindsErrorMonitor( + errorMonitor: EmptyErrorMonitor, + ): ErrorMonitor + @Binds fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index fa4bde8b8..ceb65b3e2 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -27,7 +27,9 @@ import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsR import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor +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.StateErrorMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneBroadcastMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import dagger.Binds @@ -69,6 +71,11 @@ abstract class DataModule { networkMonitor: ConnectivityManagerNetworkMonitor, ): NetworkMonitor + @Binds + internal abstract fun bindsErrorMonitor( + errorMonitor: StateErrorMonitor, + ): ErrorMonitor + @Binds internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ErrorMonitor.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ErrorMonitor.kt new file mode 100644 index 000000000..e5d967a86 --- /dev/null +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/ErrorMonitor.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.util + +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import kotlinx.coroutines.flow.Flow + +/** + * Interface for handling messages. + */ +interface ErrorMonitor { + fun addMessageByString(message: String): MessageData + + fun addMessageByData(message: MessageData) + + fun clearMessage(message: MessageData) + + fun clearAllMessages() + + val messages: Flow> +} diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitor.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitor.kt new file mode 100644 index 000000000..76efe3e37 --- /dev/null +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitor.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.util + +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import com.google.samples.apps.nowinandroid.core.model.data.MessageType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +/** + * Interface implementation for handling general errors. + */ + +class StateErrorMonitor @Inject constructor() : ErrorMonitor { + /** + * List of [MessageData] to be shown + */ + override val messages = MutableStateFlow>(emptyList()) + + /** + * Creates a [MessageData] and adds it to the list. + * @param message: String value for message to add. + */ + override fun addMessageByString(message: String): MessageData { + val data = MessageData(type = MessageType.MESSAGE(message)) + messages.update { it + data } + + return data + } + + /** + * Add a [MessageData] to the list. + * @param message: [MessageData] to add. + */ + override fun addMessageByData(message: MessageData) { + messages.update { it + message } + } + + /** + * Removes the [MessageData] from the list. + */ + override fun clearMessage(message: MessageData) { + messages.update { list -> list.filterNot { it == message } } + } + + /** + * Remove all from list, reset to empty list + */ + override fun clearAllMessages() { + messages.update { emptyList() } + } +} diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitorTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitorTest.kt new file mode 100644 index 000000000..3e928cea7 --- /dev/null +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/util/StateErrorMonitorTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.samples.apps.nowinandroid.core.data.util.StateErrorMonitor +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import com.google.samples.apps.nowinandroid.core.model.data.MessageType +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Unit tests for [StateErrorMonitor]. + */ + +class StateErrorMonitorTest { + + // Subject under test. + private lateinit var state: StateErrorMonitor + + private lateinit var messages: List + + private val testString = "Test Error Message" + private val testData = MessageData(MessageType.MESSAGE("Test Error Message 1")) + private val testData2 = MessageData(MessageType.MESSAGE("Test Error Message 2")) + + @Before + fun setup() { + state = StateErrorMonitor() + messages = emptyList() + } + + @Test + fun whenErrorIsNotAdded_NullIsPresent() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + assertEquals( + emptyList(), + messages, + ) + } + + @Test + fun whenErrorIsAddedByString_ErrorMessageIsPresent() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + + val expect = state.addMessageByString(testString) + + assertEquals( + expect, + messages.firstOrNull(), + ) + } + + @Test + fun whenErrorIsAddedByData_ErrorMessageIsPresent() = runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + + state.addMessageByData(testData) + + assertEquals( + testData, + messages.firstOrNull(), + ) + } + + @Test + fun whenErrorsAreAdded_FirstErrorMessageIsPresent() = + runTest(UnconfinedTestDispatcher()) { + state.addMessageByData(testData) + state.addMessageByString(testString) + + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + + assertEquals( + testData, + messages.firstOrNull(), + ) + } + + @Test + fun whenErrorIsCleared_ErrorMessageIsNotPresent() = + runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + state.addMessageByData(testData) + state.clearMessage(testData) + + assertEquals( + emptyList(), + messages, + ) + } + + @Test + fun whenErrorsAreCleared_NextErrorMessageIsPresent() = + runTest(UnconfinedTestDispatcher()) { + backgroundScope.launch { + state.messages.collect { + messages = it + } + } + state.addMessageByData(testData) + state.addMessageByData(testData2) + + state.clearMessage(testData) + + assertEquals( + testData2, + messages.firstOrNull(), + ) + } +} diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/MessageData.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/MessageData.kt new file mode 100644 index 000000000..7fc5eb8f2 --- /dev/null +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/MessageData.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.model.data + +/** + * [MessageData] + * Class to hold messages type objects with actions + */ +data class MessageData( + val type: MessageType, + val label: String? = null, + val onConfirm: (() -> Unit)? = null, + val onDelay: (() -> Unit)? = null, +) + +/** + * Specified Errors + */ +sealed class MessageType { + data object OFFLINE : MessageType() + data class MESSAGE(val value: String) : MessageType() + data object UNKNOWN : MessageType() +} diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestErrorMonitor.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestErrorMonitor.kt new file mode 100644 index 000000000..ca28650a5 --- /dev/null +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestErrorMonitor.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.util + +import com.google.samples.apps.nowinandroid.core.data.util.ErrorMonitor +import com.google.samples.apps.nowinandroid.core.model.data.MessageData +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.MESSAGE +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.OFFLINE +import com.google.samples.apps.nowinandroid.core.model.data.MessageType.UNKNOWN +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class TestErrorMonitor : ErrorMonitor { + + private val _messages = MutableStateFlow>(emptyList()) + + override fun addMessageByString(message: String): MessageData { + TODO("Not yet implemented") + } + + override fun addMessageByData(message: MessageData) { + TODO("Not yet implemented") + } + + override fun clearMessage(message: MessageData) { + TODO("Not yet implemented") + } + + override fun clearAllMessages() { + _messages.value = emptyList() + } + + override val messages: Flow> + get() = _messages + + /** + * Test-only API to add message types + */ + + fun setOfflineMessage() { + _messages.value = listOf(OFFLINE_MESSAGE) + } + + fun addMessage() { + _messages.value.plus(MESSAGE_MESSAGE) + } + + fun addUnknownMessage() { + _messages.value.plus(UNKNOWN_MESSAGE) + } + + companion object { + val OFFLINE_MESSAGE = MessageData(OFFLINE) + val MESSAGE_MESSAGE = MessageData(MESSAGE("Message"), "Title", {}, {}) + val UNKNOWN_MESSAGE = MessageData(UNKNOWN) + } +}