diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt index c498c03dd..cd4b40a50 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -25,7 +25,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.google.accompanist.testharness.TestHarness +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +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.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule @@ -63,6 +66,11 @@ class NavigationUiTest { @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() + val userNewsResourceRepository = CompositeUserNewsResourceRepository( + newsRepository = TestNewsRepository(), + userDataRepository = TestUserDataRepository(), + ) + @Inject lateinit var networkMonitor: NetworkMonitor @@ -81,6 +89,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -100,6 +109,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -119,6 +129,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -138,6 +149,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -157,6 +169,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -176,6 +189,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -195,6 +209,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -214,6 +229,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -233,6 +249,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 64896a544..2457af900 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -30,6 +30,9 @@ import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.composable import androidx.navigation.createGraph 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.TestNetworkMonitor import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -56,6 +59,9 @@ class NiaAppStateTest { // Create the test dependencies. private val networkMonitor = TestNetworkMonitor() + private val userNewsResourceRepository = + CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository()) + // Subject under test. private lateinit var state: NiaAppState @@ -67,10 +73,11 @@ class NiaAppStateTest { val navController = rememberTestNavController() state = remember(navController) { NiaAppState( - windowSizeClass = getCompactWindowClass(), navController = navController, - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = getCompactWindowClass(), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -92,6 +99,7 @@ class NiaAppStateTest { state = rememberNiaAppState( windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -105,10 +113,11 @@ class NiaAppStateTest { fun niaAppState_showBottomBar_compact() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = getCompactWindowClass(), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = getCompactWindowClass(), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -120,10 +129,11 @@ class NiaAppStateTest { fun niaAppState_showNavRail_medium() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -135,10 +145,11 @@ class NiaAppStateTest { fun niaAppState_showNavRail_large() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -150,10 +161,11 @@ class NiaAppStateTest { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index 5fc9d0525..79d556f73 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -40,6 +40,7 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success 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.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -67,6 +68,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var analyticsHelper: AnalyticsHelper + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -119,6 +123,7 @@ class MainActivity : ComponentActivity() { NiaApp( networkMonitor = networkMonitor, windowSizeClass = calculateWindowSizeClass(this), + userNewsResourceRepository = userNewsResourceRepository, ) } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 9565af2f8..780849cf2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -44,16 +44,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp 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.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground @@ -81,9 +85,11 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR fun NiaApp( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, appState: NiaAppState = rememberNiaAppState( networkMonitor = networkMonitor, windowSizeClass = windowSizeClass, + userNewsResourceRepository = userNewsResourceRepository, ), ) { val shouldShowGradientBackground = @@ -128,8 +134,10 @@ fun NiaApp( snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (appState.shouldShowBottomBar) { + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() NiaBottomBar( destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, currentDestination = appState.currentDestination, modifier = Modifier.testTag("NiaBottomBar"), @@ -211,6 +219,7 @@ private fun NiaNavRail( imageVector = icon.imageVector, contentDescription = null, ) + is DrawableResourceIcon -> Icon( painter = painterResource(id = icon.id), contentDescription = null, @@ -218,6 +227,7 @@ private fun NiaNavRail( } }, label = { Text(stringResource(destination.iconTextId)) }, + ) } } @@ -226,6 +236,7 @@ private fun NiaNavRail( @Composable private fun NiaBottomBar( destinations: List, + destinationsWithUnreadResources: Set, onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination?, modifier: Modifier = Modifier, @@ -234,6 +245,7 @@ private fun NiaBottomBar( modifier = modifier, ) { destinations.forEach { destination -> + val hasUnread = destinationsWithUnreadResources.contains(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) NiaNavigationBarItem( selected = selected, @@ -257,11 +269,31 @@ private fun NiaBottomBar( } }, label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) notificationDot() else Modifier, ) } } } +@Composable +private fun notificationDot(): Modifier { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + return Modifier.drawWithContent { + drawContent() + drawCircle( + tertiaryColor, + radius = 5.dp.toPx(), + // This is based on the dimensions of the NavigationBar's "indicator pill"; + // however, its parameters are private, so we must depend on them implicitly + // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) + center = center + Offset( + 64.dp.toPx() * .45f, + 32.dp.toPx() * -.45f - 6.dp.toPx(), + ), + ) + } +} + private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = this?.hierarchy?.any { it.route?.contains(destination.name, true) ?: false diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 7f655af21..df6fe1da2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState 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.NetworkMonitor import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute @@ -47,6 +48,8 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_Y import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS 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 @@ -54,12 +57,25 @@ import kotlinx.coroutines.flow.stateIn fun rememberNiaAppState( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, coroutineScope: CoroutineScope = rememberCoroutineScope(), navController: NavHostController = rememberNavController(), ): NiaAppState { NavigationTrackingSideEffect(navController) - return remember(navController, coroutineScope, windowSizeClass, networkMonitor) { - NiaAppState(navController, coroutineScope, windowSizeClass, networkMonitor) + return remember( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + userNewsResourceRepository, + ) { + NiaAppState( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + userNewsResourceRepository, + ) } } @@ -69,6 +85,7 @@ class NiaAppState( val coroutineScope: CoroutineScope, val windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, ) { val currentDestination: NavDestination? @Composable get() = navController @@ -105,6 +122,22 @@ class NiaAppState( */ val topLevelDestinations: List = TopLevelDestination.values().asList() + /** + * The top level destinations that have unread news resources. + */ + val topLevelDestinationsWithUnreadResources: StateFlow> = + userNewsResourceRepository.observeAllForFollowedTopics() + .combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources -> + setOfNotNull( + FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } }, + BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } }, + ) + }.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5_000), + initialValue = emptySet(), + ) + /** * UI logic for navigating to a top level destination in the app. Top level destinations have * only one copy of the destination of the back stack, and save and restore state whenever you diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt index 277b68717..9c21dd69a 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt @@ -24,5 +24,6 @@ import kotlin.annotation.AnnotationRetention.RUNTIME annotation class Dispatcher(val niaDispatcher: NiaDispatchers) enum class NiaDispatchers { + Default, IO, } diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt index 1b8409eff..95ec07049 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.network.di import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import dagger.Module import dagger.Provides @@ -31,4 +32,8 @@ object DispatchersModule { @Provides @Dispatcher(IO) fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(Default) + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt new file mode 100644 index 000000000..1a7a80fff --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 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.di + +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UserNewsResourceRepositoryModule { + @Binds + fun bindsUserNewsResourceRepository( + userDataRepository: CompositeUserNewsResourceRepository, + ): UserNewsResourceRepository +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt new file mode 100644 index 000000000..64e02e7d9 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 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.repository + +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a + * [UserDataRepository]. + */ +class CompositeUserNewsResourceRepository @Inject constructor( + val newsRepository: NewsRepository, + val userDataRepository: UserDataRepository, +) : UserNewsResourceRepository { + + /** + * Returns available news resources (joined with user data) matching the given query. + */ + override fun observeAll( + query: NewsResourceQuery, + ): Flow> = + newsRepository.getNewsResources(query) + .combine(userDataRepository.userData) { newsResources, userData -> + newsResources.mapToUserNewsResources(userData) + } + + /** + * Returns available news resources (joined with user data) for the followed topics. + */ + override fun observeAllForFollowedTopics(): Flow> = + userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged() + .flatMapLatest { followedTopics -> + when { + followedTopics.isEmpty() -> flowOf(emptyList()) + else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics)) + } + } + + override fun observeAllBookmarked(): Flow> = + userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged() + .flatMapLatest { bookmarkedNewsResources -> + when { + bookmarkedNewsResources.isEmpty() -> flowOf(emptyList()) + else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources)) + } + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 334209538..2559362ba 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -50,6 +50,9 @@ class OfflineFirstUserDataRepository @Inject constructor( ) } + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed) + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) analyticsHelper.logThemeChanged(themeBrand.name) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index ea093852f..5e0e7ebfc 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -43,6 +43,11 @@ interface UserDataRepository { */ suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) + /** + * Updates the viewed status for a news resource + */ + suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) + /** * Sets the desired theme brand. */ diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt new file mode 100644 index 000000000..c0f4c013a --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 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.repository + +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import kotlinx.coroutines.flow.Flow + +/** + * Data layer implementation for [UserNewsResource] + */ +interface UserNewsResourceRepository { + /** + * Returns available news resources as a stream. + */ + fun observeAll( + query: NewsResourceQuery = NewsResourceQuery( + filterTopicIds = null, + filterNewsIds = null, + ), + ): Flow> + + /** + * Returns available news resources for the user's followed topics as a stream. + */ + fun observeAllForFollowedTopics(): Flow> + + /** + * Returns the user's bookmarked news resources as a stream. + */ + fun observeAllBookmarked(): Flow> +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt index af206e5c7..74813389e 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt @@ -47,6 +47,9 @@ class FakeUserDataRepository @Inject constructor( niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) } + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed) + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) } diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt similarity index 65% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt rename to core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt index 0ff863d7c..eb4241295 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 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. @@ -14,38 +14,37 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain +package com.google.samples.apps.nowinandroid.core.data +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources 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.repository.emptyUserData -import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant -import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals -class GetUserNewsResourcesUseCaseTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() +class CompositeUserNewsResourceRepositoryTest { private val newsRepository = TestNewsRepository() private val userDataRepository = TestUserDataRepository() - val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository) + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + newsRepository = newsRepository, + userDataRepository = userDataRepository, + ) @Test fun whenNoFilters_allNewsResourcesAreReturned() = runTest { - // Obtain the user news resources stream. - val userNewsResources = useCase() + // Obtain the user news resources flow. + val userNewsResources = userNewsResourceRepository.observeAll() // Send some news resources and user data into the data repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -68,11 +67,14 @@ class GetUserNewsResourcesUseCaseTest { @Test fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. - val userNewsResources = useCase( - NewsResourceQuery( - filterTopicIds = setOf(sampleTopic1.id), - ), - ) + val userNewsResources = + userNewsResourceRepository.observeAll( + NewsResourceQuery( + filterTopicIds = setOf( + sampleTopic1.id, + ), + ), + ) // Send test data into the repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -86,6 +88,51 @@ class GetUserNewsResourcesUseCaseTest { userNewsResources.first(), ) } + + @Test + fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest { + // Obtain a stream of user news resources for the given topic id. + val userNewsResources = + userNewsResourceRepository.observeAllForFollowedTopics() + + // Send test data into the repositories. + val userData = emptyUserData.copy( + followedTopics = setOf(sampleTopic1.id), + ) + newsRepository.sendNewsResources(sampleNewsResources) + userDataRepository.setUserData(userData) + + // Check that only news resources with the given topic id are returned. + assertEquals( + sampleNewsResources + .filter { it.topics.contains(sampleTopic1) } + .mapToUserNewsResources(userData), + userNewsResources.first(), + ) + } + + @Test + fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest { + // Obtain the bookmarked user news resources flow. + val userNewsResources = userNewsResourceRepository.observeAllBookmarked() + + // Send some news resources and user data into the data repositories. + newsRepository.sendNewsResources(sampleNewsResources) + + // Construct the test user data with bookmarks and followed topics. + val userData = emptyUserData.copy( + bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id), + followedTopics = setOf(sampleTopic1.id), + ) + + userDataRepository.setUserData(userData) + + // Check that the correct news resources are returned with their bookmarked state. + assertEquals( + listOf(sampleNewsResources[0], sampleNewsResources[2]).mapToUserNewsResources(userData), + userNewsResources.first(), + ) + } } private val sampleTopic1 = Topic( diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt similarity index 94% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt rename to core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt index 8350c5178..004966ec9 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt @@ -14,16 +14,16 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain +package com.google.samples.apps.nowinandroid.core.data -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Clock import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -68,6 +68,7 @@ class UserNewsResourceTest { val userData = UserData( bookmarkedNewsResources = setOf("N1"), + viewedNewsResources = setOf("N1"), followedTopics = setOf("T1"), themeBrand = DEFAULT, darkThemeConfig = FOLLOW_SYSTEM, diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index daf1a6564..952f667f7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -66,6 +66,7 @@ class OfflineFirstUserDataRepositoryTest { assertEquals( UserData( bookmarkedNewsResources = emptySet(), + viewedNewsResources = emptySet(), followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, @@ -160,6 +161,37 @@ class OfflineFirstUserDataRepositoryTest { ) } + @Test + fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() = + runTest { + subject.setNewsResourceViewed(newsResourceId = "0", viewed = true) + + assertEquals( + setOf("0"), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + + subject.setNewsResourceViewed(newsResourceId = "1", viewed = true) + + assertEquals( + setOf("0", "1"), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + + assertEquals( + niaPreferencesDataSource.userData + .map { it.viewedNewsResources } + .first(), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + } + @Test fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = testScope.runTest { diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index f5751193a..33c04b70d 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -33,6 +33,7 @@ class NiaPreferencesDataSource @Inject constructor( .map { UserData( bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, + viewedNewsResources = it.viewedNewsResourceIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys, themeBrand = when (it.themeBrand) { null, @@ -137,6 +138,18 @@ class NiaPreferencesDataSource @Inject constructor( } } + suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + userPreferences.updateData { + it.copy { + if (viewed) { + viewedNewsResourceIds.put(newsResourceId, true) + } else { + viewedNewsResourceIds.remove(newsResourceId) + } + } + } + } + suspend fun getChangeListVersions() = userPreferences.data .map { ChangeListVersions( diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index 5288c04ea..11386613c 100644 --- a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -40,6 +40,7 @@ message UserPreferences { map followed_topic_ids = 13; map followed_author_ids = 14; map bookmarked_news_resource_ids = 15; + map viewed_news_resource_ids = 20; ThemeBrandProto theme_brand = 16; DarkThemeConfigProto dark_theme_config = 17; @@ -47,4 +48,6 @@ message UserPreferences { bool should_hide_onboarding = 18; bool use_dynamic_color = 19; + + // NEXT AVAILABLE ID: 21 } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt index ccc7e4ee1..c3c045d44 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt @@ -20,7 +20,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import javax.inject.Inject diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt deleted file mode 100644 index 393b7b08b..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2022 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.domain - -import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.UserData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNot -import javax.inject.Inject - -/** - * A use case responsible for obtaining news resources with their associated bookmarked (also known - * as "saved") state. - */ -class GetUserNewsResourcesUseCase @Inject constructor( - private val newsRepository: NewsRepository, - private val userDataRepository: UserDataRepository, -) { - /** - * Returns a list of UserNewsResources which match the supplied set of topic ids. - * - * @param query - Summary of query parameters for news resources. - */ - operator fun invoke( - query: NewsResourceQuery = NewsResourceQuery(), - ): Flow> = - newsRepository.getNewsResources( - query = query, - ).mapToUserNewsResources(userDataRepository.userData) -} - -private fun Flow>.mapToUserNewsResources( - userDataStream: Flow, -): Flow> = - filterNot { it.isEmpty() } - .combine(userDataStream) { newsResources, userData -> - newsResources.mapToUserNewsResources(userData) - } diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt index 8bf63aea4..42a31f858 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt similarity index 81% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt rename to core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt index 7b59df412..cef319c5f 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 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. @@ -14,9 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.model - -import com.google.samples.apps.nowinandroid.core.model.data.Topic +package com.google.samples.apps.nowinandroid.core.model.data /** * A [topic] with the additional information for whether or not it is followed. diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt index 638b90d36..6a22e4ff5 100644 --- a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt @@ -21,6 +21,7 @@ package com.google.samples.apps.nowinandroid.core.model.data */ data class UserData( val bookmarkedNewsResources: Set, + val viewedNewsResources: Set, val followedTopics: Set, val themeBrand: ThemeBrand, val darkThemeConfig: DarkThemeConfig, diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt similarity index 85% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt rename to core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt index 4e12ec95b..251911930 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 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. @@ -14,11 +14,8 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.model +package com.google.samples.apps.nowinandroid.core.model.data -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType -import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.datetime.Instant /** @@ -35,6 +32,7 @@ data class UserNewsResource internal constructor( val type: NewsResourceType, val followableTopics: List, val isSaved: Boolean, + val hasBeenViewed: Boolean, ) { constructor(newsResource: NewsResource, userData: UserData) : this( id = newsResource.id, @@ -51,6 +49,7 @@ data class UserNewsResource internal constructor( ) }, isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), + hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id), ) } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt index 40e9327d3..32a0cd127 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.core.testing.data -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic /* ktlint-disable max-line-length */ diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt index 381160006..987b48b57 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt @@ -16,12 +16,14 @@ package com.google.samples.apps.nowinandroid.core.testing.data -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -30,6 +32,7 @@ import kotlinx.datetime.toInstant /* ktlint-disable max-line-length */ val userNewsResourcesTestData: List = UserData( bookmarkedNewsResources = setOf("1", "4"), + viewedNewsResources = setOf("1", "2", "4"), followedTopics = emptySet(), themeBrand = ThemeBrand.ANDROID, darkThemeConfig = DarkThemeConfig.DARK, @@ -53,7 +56,7 @@ val userNewsResourcesTestData: List = UserData( second = 0, nanosecond = 0, ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, + type = Codelab, topics = listOf(topicsTestData[2]), ), userData = userData, @@ -69,7 +72,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://youtu.be/-fJ6poHQrjM", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = NewsResourceType.Video, + type = Video, topics = topicsTestData.take(2), ), userData = userData, @@ -85,7 +88,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://youtu.be/ZARz0pjm5YM", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = NewsResourceType.Video, + type = Video, topics = listOf(topicsTestData[2]), ), userData = userData, @@ -99,7 +102,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://developer.android.com/jetpack/androidx/versions/all-channel", headerImageUrl = "", publishDate = Instant.parse("2022-10-01T00:00:00.000Z"), - type = NewsResourceType.Unknown, + type = Unknown, topics = listOf(topicsTestData[2]), ), userData = userData, diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index e1b86cd63..66ac80868 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.filterNotNull val emptyUserData = UserData( bookmarkedNewsResources = emptySet(), + viewedNewsResources = emptySet(), followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, @@ -72,6 +73,21 @@ class TestUserDataRepository : UserDataRepository { } } + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + currentUserData.let { current -> + _userData.tryEmit( + current.copy( + viewedNewsResources = + if (viewed) { + current.viewedNewsResources + newsResourceId + } else { + current.viewedNewsResources - newsResourceId + }, + ), + ) + } + } + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { currentUserData.let { current -> _userData.tryEmit(current.copy(themeBrand = themeBrand)) diff --git a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt index 712771422..a495a6266 100644 --- a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt +++ b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt @@ -20,6 +20,7 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData @@ -39,6 +40,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithKnownResourceType, isBookmarked = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -67,6 +69,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithUnknownResourceType, isBookmarked = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -101,4 +104,52 @@ class NewsResourceCardTest { .assertContentDescriptionEquals(expectedContentDescription) } } + + @Test + fun testUnreadDot_displayedWhenUnread() { + val unreadNews = userNewsResourcesTestData[2] + + composeTestRule.setContent { + NewsResourceCardExpanded( + userNewsResource = unreadNews, + isBookmarked = false, + hasBeenViewed = false, + onToggleBookmark = {}, + onClick = {}, + onTopicClick = {}, + ) + } + + composeTestRule + .onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.unread_resource_dot_content_description, + ), + ) + .assertIsDisplayed() + } + + @Test + fun testUnreadDot_notDisplayedWhenRead() { + val readNews = userNewsResourcesTestData[0] + + composeTestRule.setContent { + NewsResourceCardExpanded( + userNewsResource = readNews, + isBookmarked = false, + hasBeenViewed = true, + onToggleBookmark = {}, + onClick = {}, + onTopicClick = {}, + ) + } + + composeTestRule + .onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.unread_resource_dot_content_description, + ), + ) + .assertDoesNotExist() + } } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt index 3c83b973c..0dd9501b4 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic /* ktlint-disable max-line-length */ diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index aab3ea3d1..981de26f9 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * An extension on [LazyListScope] defining a feed with news resources. @@ -50,6 +50,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyGridScope.newsFeed( feedState: NewsFeedUiState, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { when (feedState) { @@ -71,7 +72,9 @@ fun LazyGridScope.newsFeed( newsResourceId = userNewsResource.id, ) launchCustomChromeTab(context, resourceUrl, backgroundColor) + onNewsResourceViewed(userNewsResource.id) }, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, @@ -124,6 +127,7 @@ private fun NewsFeedLoadingPreview() { newsFeed( feedState = NewsFeedUiState.Loading, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -142,6 +146,7 @@ private fun NewsFeedContentPreview( newsFeed( feedState = NewsFeedUiState.Success(userNewsResources), onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index a09b2e94a..a6a7aafc9 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.ui +import androidx.compose.foundation.Canvas import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card @@ -41,7 +43,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -58,10 +62,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant import java.time.ZoneId @@ -78,6 +82,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR fun NewsResourceCardExpanded( userNewsResource: UserNewsResource, isBookmarked: Boolean, + hasBeenViewed: Boolean, onToggleBookmark: () -> Unit, onClick: () -> Unit, onTopicClick: (String) -> Unit, @@ -114,7 +119,16 @@ fun NewsResourceCardExpanded( BookmarkButton(isBookmarked, onToggleBookmark) } Spacer(modifier = Modifier.height(12.dp)) - NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) + Row(verticalAlignment = Alignment.CenterVertically) { + if (!hasBeenViewed) { + NotificationDot( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(8.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) + } + NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) + } Spacer(modifier = Modifier.height(12.dp)) NewsResourceShortDescription(userNewsResource.content) Spacer(modifier = Modifier.height(12.dp)) @@ -182,6 +196,24 @@ fun BookmarkButton( ) } +@Composable +fun NotificationDot( + color: Color, + modifier: Modifier = Modifier, +) { + val description = stringResource(R.string.unread_resource_dot_content_description) + Canvas( + modifier = modifier + .semantics { contentDescription = description }, + onDraw = { + drawCircle( + color, + radius = size.minDimension / 2, + ) + }, + ) +} + @Composable fun dateFormatted(publishDate: Instant): String { var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) } @@ -305,6 +337,7 @@ private fun ExpandedNewsResourcePreview( NewsResourceCardExpanded( userNewsResource = userNewsResources[0], isBookmarked = true, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index 8429dbdda..5cf7d7313 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of @@ -37,6 +37,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyListScope.userNewsResourceCardItems( items: List, onToggleBookmark: (item: UserNewsResource) -> Unit, + onNewsResourceViewed: (String) -> Unit, onItemClick: ((item: UserNewsResource) -> Unit)? = null, onTopicClick: (String) -> Unit, itemModifier: Modifier = Modifier, @@ -52,6 +53,7 @@ fun LazyListScope.userNewsResourceCardItems( NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { analyticsHelper.logNewsResourceOpened( @@ -61,6 +63,7 @@ fun LazyListScope.userNewsResourceCardItems( null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) else -> onItemClick(userNewsResource) } + onNewsResourceViewed(userNewsResource.id) }, onTopicClick = onTopicClick, modifier = itemModifier, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index e32aa1a57..3f3f9bddd 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -17,7 +17,6 @@ package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType @@ -25,6 +24,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -40,6 +40,7 @@ class UserNewsResourcePreviewParameterProvider : PreviewParameterProviderUnbookmark Back + Unread + Open Resource Link %1$s • %2$s diff --git a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 3662bd47f..680c6dcf7 100644 --- a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -52,6 +52,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Loading, removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -71,6 +72,7 @@ class BookmarksScreenTest { ), removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -113,6 +115,7 @@ class BookmarksScreenTest { removeFromBookmarksCalled = true }, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -143,6 +146,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success(emptyList()), removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourceViewed = {}, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 0201887a3..2ed6a76b3 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -54,7 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success @@ -73,6 +73,7 @@ internal fun BookmarksRoute( BookmarksScreen( feedState = feedState, removeFromBookmarks = viewModel::removeFromSavedResources, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, modifier = modifier, ) @@ -86,13 +87,14 @@ internal fun BookmarksRoute( internal fun BookmarksScreen( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { when (feedState) { Loading -> LoadingState(modifier) is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onTopicClick, modifier) + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) } else { EmptyState(modifier) } @@ -115,6 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { private fun BookmarksGrid( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -133,6 +136,7 @@ private fun BookmarksGrid( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) item(span = { GridItemSpan(maxLineSpan) }) { @@ -198,6 +202,7 @@ private fun BookmarksGridPreview( BookmarksGrid( feedState = Success(userNewsResources), removeFromBookmarks = {}, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index fe631c287..8a1869322 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -19,14 +19,13 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -36,23 +35,28 @@ import javax.inject.Inject @HiltViewModel class BookmarksViewModel @Inject constructor( private val userDataRepository: UserDataRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { - val feedUiState: StateFlow = getSaveableNewsResources() - .filterNot { it.isEmpty() } - .map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources. - .map, NewsFeedUiState>(NewsFeedUiState::Success) - .onStart { emit(Loading) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = Loading, - ) + val feedUiState: StateFlow = + userNewsResourceRepository.observeAllBookmarked() + .map, NewsFeedUiState>(NewsFeedUiState::Success) + .onStart { emit(Loading) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Loading, + ) fun removeFromSavedResources(newsResourceId: String) { viewModelScope.launch { userDataRepository.updateNewsResourceBookmark(newsResourceId, false) } } + + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + viewModelScope.launch { + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) + } + } } diff --git a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt index ae4445197..6469a684b 100644 --- a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt +++ b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository @@ -43,7 +43,7 @@ class BookmarksViewModelTest { private val userDataRepository = TestUserDataRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -53,7 +53,7 @@ class BookmarksViewModelTest { fun setup() { viewModel = BookmarksViewModel( userDataRepository = userDataRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) } diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index ab712cbb5..fde215aa1 100644 --- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -56,6 +56,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -79,6 +80,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -108,6 +110,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -152,6 +155,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -189,6 +193,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -212,6 +217,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -236,6 +242,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 767bede9a..06c73c971 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -79,7 +79,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @@ -105,6 +105,7 @@ internal fun ForYouRoute( onTopicClick = onTopicClick, saveFollowedTopics = viewModel::dismissOnboarding, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, modifier = modifier, ) } @@ -118,6 +119,7 @@ internal fun ForYouScreen( onTopicClick: (String) -> Unit, saveFollowedTopics: () -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading @@ -160,6 +162,7 @@ internal fun ForYouScreen( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) @@ -397,6 +400,7 @@ fun ForYouScreenPopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -420,6 +424,7 @@ fun ForYouScreenOfflinePopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -446,6 +451,7 @@ fun ForYouScreenTopicSelection( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -464,6 +470,7 @@ fun ForYouScreenLoading() { onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -487,6 +494,7 @@ fun ForYouScreenPopulatedAndLoading( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 376624376..18d24118b 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -18,22 +18,16 @@ package com.google.samples.apps.nowinandroid.feature.foryou import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery 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.SyncManager import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -43,7 +37,7 @@ import javax.inject.Inject class ForYouViewModel @Inject constructor( syncManager: SyncManager, private val userDataRepository: UserDataRepository, - getUserNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, getFollowableTopics: GetFollowableTopicsUseCase, ) : ViewModel() { @@ -58,7 +52,7 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - userDataRepository.getFollowedUserNewsResources(getUserNewsResources) + userNewsResourceRepository.observeAllForFollowedTopics() .map(NewsFeedUiState::Success) .stateIn( scope = viewModelScope, @@ -95,55 +89,15 @@ class ForYouViewModel @Inject constructor( } } - fun dismissOnboarding() { + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { viewModelScope.launch { - userDataRepository.setShouldHideOnboarding(true) + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } -} -/** - * Obtain a flow of user news resources whose topics match those the user is following. - * - * getUserNewsResources: The `UseCase` used to obtain the flow of user news resources. - */ -private fun UserDataRepository.getFollowedUserNewsResources( - getUserNewsResources: GetUserNewsResourcesUseCase, -): Flow> = userData - // Map the user data into a set of followed topic IDs or null if we should return an empty list. - .map { userData -> - if (userData.shouldShowEmptyFeed()) { - null - } else { - userData.followedTopics - } - } - // Only emit a set of followed topic IDs if it's changed. This avoids calling potentially - // expensive operations (like setting up a new flow) when nothing has changed. - .distinctUntilChanged() - // getUserNewsResources returns a flow, so we have a flow inside a flow. flatMapLatest moves - // the inner flow (the one we want to return) to the outer flow and cancels any previous flows - // created by getUserNewsResources. - .flatMapLatest { followedTopics -> - if (followedTopics == null) { - flowOf(emptyList()) - } else { - getUserNewsResources( - NewsResourceQuery( - filterTopicIds = followedTopics, - ), - ) + fun dismissOnboarding() { + viewModelScope.launch { + userDataRepository.setShouldHideOnboarding(true) } } - -/** - * If the user hasn't completed the onboarding and hasn't selected any interests - * show an empty news list to clearly demonstrate that their selections affect the - * news articles they will see. - * - * Note: It should not be possible for the user to get into a state where the onboarding - * is not displayed AND they haven't followed any topics, however, this method is to safeguard - * against that scenario in future. - */ -private fun UserData.shouldShowEmptyFeed() = - !shouldHideOnboarding && followedTopics.isEmpty() +} diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt index faf368b1e..58f4f1683 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic /** * A sealed hierarchy describing the onboarding state for the for you screen. diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index db8178b89..62993dc9f 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -16,14 +16,14 @@ package com.google.samples.apps.nowinandroid.feature.foryou +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository @@ -56,7 +56,7 @@ class ForYouViewModelTest { private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -72,7 +72,7 @@ class ForYouViewModelTest { viewModel = ForYouViewModel( syncManager = syncManager, userDataRepository = userDataRepository, - getUserNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, getFollowableTopics = getFollowableTopicsUseCase, ) } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index 8f863ba5a..e618c1c9f 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index d6ef94521..debc49bcd 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -21,7 +21,7 @@ import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 71667e4dc..289776164 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @Composable fun TopicsTabContent( diff --git a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index e47b25021..c46cb7780 100644 --- a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.interests import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository diff --git a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 3a267d7e7..94f86a8e4 100644 --- a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -59,6 +59,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -78,6 +79,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -102,6 +104,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -124,6 +127,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index da6981010..fd408f9cf 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -51,8 +51,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilte import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank @@ -79,6 +79,7 @@ internal fun TopicRoute( onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, ) } @@ -92,6 +93,7 @@ internal fun TopicScreen( onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() @@ -127,6 +129,7 @@ internal fun TopicScreen( news = newsUiState, imageUrl = topicUiState.followableTopic.topic.imageUrl, onBookmarkChanged = onBookmarkChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) } @@ -143,6 +146,7 @@ private fun LazyListScope.TopicBody( news: NewsUiState, imageUrl: String, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { // TODO: Show icon if available @@ -150,7 +154,7 @@ private fun LazyListScope.TopicBody( TopicHeader(name, description, imageUrl) } - userNewsResourceCards(news, onBookmarkChanged, onTopicClick) + userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick) } @Composable @@ -181,6 +185,7 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) { private fun LazyListScope.userNewsResourceCards( news: NewsUiState, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { when (news) { @@ -188,6 +193,7 @@ private fun LazyListScope.userNewsResourceCards( userNewsResourceCardItems( items = news.news, onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, itemModifier = Modifier.padding(24.dp), ) @@ -214,6 +220,7 @@ private fun TopicBodyPreview() { news = NewsUiState.Success(emptyList()), imageUrl = "", onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -271,6 +278,7 @@ fun TopicScreenPopulated( onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -288,6 +296,7 @@ fun TopicScreenLoading() { onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index fcabff16b..2b2565f9e 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -22,11 +22,11 @@ import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery 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.decoder.StringDecoder -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs @@ -46,7 +46,7 @@ class TopicViewModel @Inject constructor( stringDecoder: StringDecoder, private val userDataRepository: UserDataRepository, topicsRepository: TopicsRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) @@ -67,7 +67,7 @@ class TopicViewModel @Inject constructor( val newUiState: StateFlow = newsUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, - getSaveableNewsResources = getSaveableNewsResources, + userNewsResourceRepository = userNewsResourceRepository, ) .stateIn( scope = viewModelScope, @@ -86,6 +86,12 @@ class TopicViewModel @Inject constructor( userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) } } + + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + viewModelScope.launch { + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) + } + } } private fun topicUiState( @@ -135,14 +141,12 @@ private fun topicUiState( private fun newsUiState( topicId: String, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, userDataRepository: UserDataRepository, ): Flow { // Observe news - val newsStream: Flow> = getSaveableNewsResources( - NewsResourceQuery( - filterTopicIds = setOf(element = topicId), - ), + val newsStream: Flow> = userNewsResourceRepository.observeAll( + NewsResourceQuery(filterTopicIds = setOf(element = topicId)), ) // Observe bookmarks diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index dfed60385..ff7a88160 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -17,8 +17,8 @@ package com.google.samples.apps.nowinandroid.feature.topic import androidx.lifecycle.SavedStateHandle -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -53,7 +53,7 @@ class TopicViewModelTest { private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -66,7 +66,7 @@ class TopicViewModelTest { stringDecoder = FakeStringDecoder(), userDataRepository = userDataRepository, topicsRepository = topicsRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) }