Incorporate code review changes: Move UserNewsResourceRepository to data

module; move UserNewsResource to model module. Implement unread dot for
bookmarked articles. Keep the flows cold in UserNewsResourceRepository.
pull/595/head
James Rose 2 years ago
parent ebfbb5bafd
commit 57c13d84bd

@ -86,11 +86,9 @@ dependencies {
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:domain"))
implementation(project(":core:ui")) implementation(project(":core:ui"))
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:analytics")) implementation(project(":core:analytics"))

@ -25,16 +25,14 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness 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.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository 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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -69,7 +67,6 @@ class NavigationUiTest {
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository( val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = TestNewsRepository(), newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(), userDataRepository = TestUserDataRepository(),
) )

@ -30,6 +30,9 @@ import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.createGraph import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController 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 com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -56,6 +59,9 @@ class NiaAppStateTest {
// Create the test dependencies. // Create the test dependencies.
private val networkMonitor = TestNetworkMonitor() private val networkMonitor = TestNetworkMonitor()
private val userNewsResourceRepository =
CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository())
// Subject under test. // Subject under test.
private lateinit var state: NiaAppState private lateinit var state: NiaAppState
@ -67,10 +73,11 @@ class NiaAppStateTest {
val navController = rememberTestNavController() val navController = rememberTestNavController()
state = remember(navController) { state = remember(navController) {
NiaAppState( NiaAppState(
windowSizeClass = getCompactWindowClass(),
navController = navController, navController = navController,
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -92,6 +99,7 @@ class NiaAppStateTest {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -105,10 +113,11 @@ class NiaAppStateTest {
fun niaAppState_showBottomBar_compact() = runTest { fun niaAppState_showBottomBar_compact() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -120,10 +129,11 @@ class NiaAppStateTest {
fun niaAppState_showNavRail_medium() = runTest { fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, 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 { fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -150,10 +161,11 @@ class NiaAppStateTest {
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }

@ -40,9 +40,9 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success 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.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper 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.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp

@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R 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.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
@ -70,7 +71,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -85,11 +85,12 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
fun NiaApp( fun NiaApp(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
userNewsResourceRepository = userNewsResourceRepository,
), ),
userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
@ -133,14 +134,7 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
val forYouNewsResources by userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
.collectAsStateWithLifecycle(emptyList())
val unreadDestinations =
when {
forYouNewsResources.all { it.isViewed } -> emptySet()
else -> setOf(TopLevelDestination.FOR_YOU)
}
NiaBottomBar( NiaBottomBar(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations, destinationsWithUnreadResources = unreadDestinations,
@ -275,30 +269,31 @@ private fun NiaBottomBar(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) { modifier = if (hasUnread) notificationDot() else Modifier,
val tertiaryColor = MaterialTheme.colorScheme.tertiary
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(),
),
)
}
} 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) = private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any { this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false it.route?.contains(destination.name, true) ?: false

@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.tracing.trace 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.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute 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 com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -54,12 +57,25 @@ import kotlinx.coroutines.flow.stateIn
fun rememberNiaAppState( fun rememberNiaAppState(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(navController)
return remember(navController, coroutineScope, windowSizeClass, networkMonitor) { return remember(
NiaAppState(navController, coroutineScope, windowSizeClass, networkMonitor) navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
) {
NiaAppState(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
)
} }
} }
@ -69,6 +85,7 @@ class NiaAppState(
val coroutineScope: CoroutineScope, val coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass, val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
val currentDestination: NavDestination? val currentDestination: NavDestination?
@Composable get() = navController @Composable get() = navController
@ -105,6 +122,22 @@ class NiaAppState(
*/ */
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList() val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList()
/**
* The top level destinations that have unread news resources.
*/
val topLevelDestinationsWithUnreadResources: StateFlow<Set<TopLevelDestination>> =
userNewsResourceRepository.getUserNewsResourcesForFollowedTopics()
.combine(userNewsResourceRepository.getBookmarkedUserNewsResources()) { 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 * 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 * only one copy of the destination of the back stack, and save and restore state whenever you

@ -14,10 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.domain.di package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn

@ -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 getUserNewsResources(
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
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 getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()
.flatMapLatest { followedTopics ->
when {
followedTopics.isEmpty() -> flowOf(emptyList())
else -> getUserNewsResources(NewsResourceQuery(filterTopicIds = followedTopics))
}
}
override fun getBookmarkedUserNewsResources(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged()
.flatMapLatest { bookmarkedNewsResources ->
when {
bookmarkedNewsResources.isEmpty() -> flowOf(emptyList())
else -> getUserNewsResources(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources))
}
}
}

@ -50,8 +50,8 @@ class OfflineFirstUserDataRepository @Inject constructor(
) )
} }
override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =
niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)
override suspend fun setThemeBrand(themeBrand: ThemeBrand) { override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand) niaPreferencesDataSource.setThemeBrand(themeBrand)

@ -46,7 +46,7 @@ interface UserDataRepository {
/** /**
* Updates the viewed status for a news resource * Updates the viewed status for a news resource
*/ */
suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean)
/** /**
* Sets the desired theme brand. * Sets the desired theme brand.

@ -14,10 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.domain.repository package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
@ -38,4 +37,9 @@ interface UserNewsResourceRepository {
* Returns available news resources for the user's followed topics as a stream. * Returns available news resources for the user's followed topics as a stream.
*/ */
fun getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>> fun getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>>
/**
*
*/
fun getBookmarkedUserNewsResources(): Flow<List<UserNewsResource>>
} }

@ -47,8 +47,8 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
} }
override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =
niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)
override suspend fun setThemeBrand(themeBrand: ThemeBrand) { override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand) niaPreferencesDataSource.setThemeBrand(themeBrand)

@ -14,20 +14,18 @@
* limitations under the License. * 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.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository 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.repository.emptyUserData
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
@ -39,7 +37,6 @@ class CompositeUserNewsResourceRepositoryTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
@ -71,7 +68,13 @@ class CompositeUserNewsResourceRepositoryTest {
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id. // Obtain a stream of user news resources for the given topic id.
val userNewsResources = val userNewsResources =
userNewsResourceRepository.getUserNewsResources(NewsResourceQuery(filterTopicIds = setOf(sampleTopic1.id))) userNewsResourceRepository.getUserNewsResources(
NewsResourceQuery(
filterTopicIds = setOf(
sampleTopic1.id,
),
),
)
// Send test data into the repositories. // Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -107,6 +110,29 @@ class CompositeUserNewsResourceRepositoryTest {
userNewsResources.first(), userNewsResources.first(),
) )
} }
@Test
fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest {
// Obtain the bookmarked user news resources flow.
val userNewsResources = userNewsResourceRepository.getBookmarkedUserNewsResources()
// 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( private val sampleTopic1 = Topic(

@ -14,16 +14,16 @@
* limitations under the License. * 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.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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article 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.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue

@ -164,7 +164,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() =
runTest { runTest {
subject.updateNewsResourceViewed(newsResourceId = "0", viewed = true) subject.setNewsResourceViewed(newsResourceId = "0", viewed = true)
assertEquals( assertEquals(
setOf("0"), setOf("0"),
@ -173,7 +173,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
) )
subject.updateNewsResourceViewed(newsResourceId = "1", viewed = true) subject.setNewsResourceViewed(newsResourceId = "1", viewed = true)
assertEquals( assertEquals(
setOf("0", "1"), setOf("0", "1"),

@ -138,7 +138,7 @@ class NiaPreferencesDataSource @Inject constructor(
} }
} }
suspend fun toggleNewsResourceViewed(newsResourceId: String, viewed: Boolean) { suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
userPreferences.updateData { userPreferences.updateData {
it.copy { it.copy {
if (viewed) { if (viewed) {

@ -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.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME 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.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.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import javax.inject.Inject import javax.inject.Inject

@ -1,42 +0,0 @@
/*
* 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.domain.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@InstallIn(SingletonComponent::class)
@Module
object CoroutinesScopesModule {
@Singleton
@ApplicationScope
@Provides
fun providesCoroutineScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

@ -1,74 +0,0 @@
/*
* 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.domain.repository
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.di.ApplicationScope
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.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import javax.inject.Inject
/**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
* [UserDataRepository].
*/
class CompositeUserNewsResourceRepository @Inject constructor(
@ApplicationScope private val coroutineScope: CoroutineScope,
val newsRepository: NewsRepository,
val userDataRepository: UserDataRepository,
) : UserNewsResourceRepository {
private val userNewsResources =
newsRepository.getNewsResources().mapToUserNewsResources(userDataRepository.userData)
.shareIn(coroutineScope, started = WhileSubscribed(5000), replay = 1)
override fun getUserNewsResources(
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
userNewsResources.map { resources ->
resources.filter { resource ->
query.filterTopicIds?.let { topics -> resource.hasTopic(topics) } ?: true &&
query.filterNewsIds?.contains(resource.id) ?: true
}
}
override fun getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.flatMapLatest { getUserNewsResources(NewsResourceQuery(filterTopicIds = it.followedTopics)) }
private fun UserNewsResource.hasTopic(filterTopicIds: Set<String>) =
followableTopics.any { filterTopicIds.contains(it.topic.id) }
}
private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() }
.combine(userDataStream) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData)
}

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.core.domain 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.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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,9 +14,7 @@
* limitations under the License. * 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.Topic
/** /**
* A [topic] with the additional information for whether or not it is followed. * A [topic] with the additional information for whether or not it is followed.

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,11 +14,8 @@
* limitations under the License. * 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 import kotlinx.datetime.Instant
/** /**
@ -35,7 +32,7 @@ data class UserNewsResource internal constructor(
val type: NewsResourceType, val type: NewsResourceType,
val followableTopics: List<FollowableTopic>, val followableTopics: List<FollowableTopic>,
val isSaved: Boolean, val isSaved: Boolean,
val isViewed: Boolean, val hasBeenViewed: Boolean,
) { ) {
constructor(newsResource: NewsResource, userData: UserData) : this( constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id, id = newsResource.id,
@ -52,7 +49,7 @@ data class UserNewsResource internal constructor(
) )
}, },
isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), isSaved = userData.bookmarkedNewsResources.contains(newsResource.id),
isViewed = userData.viewedNewsResources.contains(newsResource.id), hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id),
) )
} }

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.core.testing.data 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 import com.google.samples.apps.nowinandroid.core.model.data.Topic
/* ktlint-disable max-line-length */ /* ktlint-disable max-line-length */

@ -16,12 +16,14 @@
package com.google.samples.apps.nowinandroid.core.testing.data 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.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData 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.Instant
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@ -54,7 +56,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
second = 0, second = 0,
nanosecond = 0, nanosecond = 0,
).toInstant(TimeZone.UTC), ).toInstant(TimeZone.UTC),
type = NewsResourceType.Codelab, type = Codelab,
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,
@ -70,7 +72,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/-fJ6poHQrjM", url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = NewsResourceType.Video, type = Video,
topics = topicsTestData.take(2), topics = topicsTestData.take(2),
), ),
userData = userData, userData = userData,
@ -86,7 +88,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://youtu.be/ZARz0pjm5YM", url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = NewsResourceType.Video, type = Video,
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,
@ -100,7 +102,7 @@ val userNewsResourcesTestData: List<UserNewsResource> = UserData(
url = "https://developer.android.com/jetpack/androidx/versions/all-channel", url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "", headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"), publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = NewsResourceType.Unknown, type = Unknown,
topics = listOf(topicsTestData[2]), topics = listOf(topicsTestData[2]),
), ),
userData = userData, userData = userData,

@ -73,7 +73,7 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) { override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
currentUserData.let { current -> currentUserData.let { current ->
_userData.tryEmit( _userData.tryEmit(
current.copy( current.copy(

@ -40,7 +40,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithKnownResourceType, userNewsResource = newsWithKnownResourceType,
isBookmarked = false, isBookmarked = false,
isViewed = false, hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -69,7 +69,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType, userNewsResource = newsWithUnknownResourceType,
isBookmarked = false, isBookmarked = false,
isViewed = false, hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -113,7 +113,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = unreadNews, userNewsResource = unreadNews,
isBookmarked = false, isBookmarked = false,
isViewed = false, hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -137,7 +137,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = readNews, userNewsResource = readNews,
isBookmarked = false, isBookmarked = false,
isViewed = true, hasBeenViewed = true,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.core.ui package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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 import com.google.samples.apps.nowinandroid.core.model.data.Topic
/* ktlint-disable max-line-length */ /* ktlint-disable max-line-length */

@ -39,7 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper 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.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. * An extension on [LazyListScope] defining a feed with news resources.
@ -48,7 +48,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
fun LazyGridScope.newsFeed( fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
when (feedState) { when (feedState) {
@ -71,9 +71,9 @@ fun LazyGridScope.newsFeed(
newsResourceTitle = userNewsResource.title, newsResourceTitle = userNewsResource.title,
) )
launchCustomChromeTab(context, resourceUrl, backgroundColor) launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourcesViewedChanged(userNewsResource.id, true) onNewsResourceViewed(userNewsResource.id)
}, },
isViewed = userNewsResource.isViewed, hasBeenViewed = userNewsResource.hasBeenViewed,
onToggleBookmark = { onToggleBookmark = {
onNewsResourcesCheckedChanged( onNewsResourcesCheckedChanged(
userNewsResource.id, userNewsResource.id,
@ -125,7 +125,7 @@ private fun NewsFeedLoadingPreview() {
newsFeed( newsFeed(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -144,7 +144,7 @@ private fun NewsFeedContentPreview(
newsFeed( newsFeed(
feedState = NewsFeedUiState.Success(userNewsResources), feedState = NewsFeedUiState.Success(userNewsResources),
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -61,10 +61,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.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.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.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant import kotlinx.datetime.toJavaInstant
import java.time.ZoneId import java.time.ZoneId
@ -81,7 +81,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR
fun NewsResourceCardExpanded( fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource, userNewsResource: UserNewsResource,
isBookmarked: Boolean, isBookmarked: Boolean,
isViewed: Boolean, hasBeenViewed: Boolean,
onToggleBookmark: () -> Unit, onToggleBookmark: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
@ -119,8 +119,8 @@ fun NewsResourceCardExpanded(
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (!isViewed) { if (!hasBeenViewed) {
Dot( NotificationDot(
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(8.dp), modifier = Modifier.size(8.dp),
) )
@ -196,7 +196,7 @@ fun BookmarkButton(
} }
@Composable @Composable
fun Dot( fun NotificationDot(
color: Color, color: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -333,7 +333,7 @@ private fun ExpandedNewsResourcePreview(
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResources[0], userNewsResource = userNewsResources[0],
isBookmarked = true, isBookmarked = true,
isViewed = false, hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},

@ -24,7 +24,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper 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 * Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of
@ -37,7 +37,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
fun LazyListScope.userNewsResourceCardItems( fun LazyListScope.userNewsResourceCardItems(
items: List<UserNewsResource>, items: List<UserNewsResource>,
onToggleBookmark: (item: UserNewsResource) -> Unit, onToggleBookmark: (item: UserNewsResource) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onItemClick: ((item: UserNewsResource) -> Unit)? = null, onItemClick: ((item: UserNewsResource) -> Unit)? = null,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
itemModifier: Modifier = Modifier, itemModifier: Modifier = Modifier,
@ -53,7 +53,7 @@ fun LazyListScope.userNewsResourceCardItems(
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResource, userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved, isBookmarked = userNewsResource.isSaved,
isViewed = userNewsResource.isViewed, hasBeenViewed = userNewsResource.hasBeenViewed,
onToggleBookmark = { onToggleBookmark(userNewsResource) }, onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
@ -61,12 +61,10 @@ fun LazyListScope.userNewsResourceCardItems(
newsResourceTitle = userNewsResource.title, newsResourceTitle = userNewsResource.title,
) )
when (onItemClick) { when (onItemClick) {
null -> { null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)
launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourcesViewedChanged(userNewsResource.id, true)
}
else -> onItemClick(userNewsResource) else -> onItemClick(userNewsResource)
} }
onNewsResourceViewed(userNewsResource.id)
}, },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
modifier = itemModifier, modifier = itemModifier,

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.ui package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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
@ -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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone

@ -52,7 +52,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -72,7 +72,7 @@ class BookmarksScreenTest {
), ),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -115,7 +115,7 @@ class BookmarksScreenTest {
removeFromBookmarksCalled = true removeFromBookmarksCalled = true
}, },
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -146,7 +146,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }

@ -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.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme 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.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
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
@ -73,7 +73,7 @@ internal fun BookmarksRoute(
BookmarksScreen( BookmarksScreen(
feedState = feedState, feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
modifier = modifier, modifier = modifier,
) )
@ -87,14 +87,14 @@ internal fun BookmarksRoute(
internal fun BookmarksScreen( internal fun BookmarksScreen(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (feedState) { when (feedState) {
Loading -> LoadingState(modifier) Loading -> LoadingState(modifier)
is Success -> if (feedState.feed.isNotEmpty()) { is Success -> if (feedState.feed.isNotEmpty()) {
BookmarksGrid(feedState, removeFromBookmarks, onNewsResourcesViewedChanged, onTopicClick, modifier) BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier)
} else { } else {
EmptyState(modifier) EmptyState(modifier)
} }
@ -117,7 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
private fun BookmarksGrid( private fun BookmarksGrid(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -136,7 +136,7 @@ private fun BookmarksGrid(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@ -202,7 +202,7 @@ private fun BookmarksGridPreview(
BookmarksGrid( BookmarksGrid(
feedState = Success(userNewsResources), feedState = Success(userNewsResources),
removeFromBookmarks = {}, removeFromBookmarks = {},
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -19,14 +19,13 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository 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.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.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
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -39,16 +38,15 @@ class BookmarksViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = userNewsResourceRepository.getUserNewsResources() val feedUiState: StateFlow<NewsFeedUiState> =
.filterNot { it.isEmpty() } userNewsResourceRepository.getBookmarkedUserNewsResources()
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources. .map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success) .onStart { emit(Loading) }
.onStart { emit(Loading) } .stateIn(
.stateIn( scope = viewModelScope,
scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000),
started = SharingStarted.WhileSubscribed(5_000), initialValue = Loading,
initialValue = Loading, )
)
fun removeFromSavedResources(newsResourceId: String) { fun removeFromSavedResources(newsResourceId: String) {
viewModelScope.launch { viewModelScope.launch {
@ -56,9 +54,9 @@ class BookmarksViewModel @Inject constructor(
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
} }
} }
} }

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.data.newsResourcesTestData
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository 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.TestUserDataRepository
@ -25,7 +25,6 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@ -45,7 +44,6 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )

@ -56,7 +56,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -80,7 +80,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -110,7 +110,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -155,7 +155,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -193,7 +193,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -217,7 +217,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
} }
@ -242,7 +242,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }

@ -81,7 +81,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.component.NiaOverlayLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.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.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@ -107,7 +107,7 @@ internal fun ForYouRoute(
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
modifier = modifier, modifier = modifier,
) )
} }
@ -121,7 +121,7 @@ internal fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading
@ -179,7 +179,7 @@ internal fun ForYouScreen(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
@ -416,7 +416,7 @@ fun ForYouScreenPopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -440,7 +440,7 @@ fun ForYouScreenOfflinePopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -466,7 +466,7 @@ fun ForYouScreenTopicSelection(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -485,7 +485,7 @@ fun ForYouScreenLoading() {
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -509,7 +509,7 @@ fun ForYouScreenPopulatedAndLoading(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -18,22 +18,16 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine 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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -58,7 +52,7 @@ class ForYouViewModel @Inject constructor(
) )
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
userDataRepository.getFollowedUserNewsResources(userNewsResourceRepository) userNewsResourceRepository.getUserNewsResourcesForFollowedTopics()
.map(NewsFeedUiState::Success) .map(NewsFeedUiState::Success)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -95,9 +89,9 @@ class ForYouViewModel @Inject constructor(
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
} }
} }
@ -107,47 +101,3 @@ class ForYouViewModel @Inject constructor(
} }
} }
} }
/**
* 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(
userNewsResourceRepository: UserNewsResourceRepository,
): Flow<List<UserNewsResource>> = 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 {
userNewsResourceRepository.getUserNewsResources(
NewsResourceQuery(filterTopicIds = followedTopics),
)
}
}
/**
* 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()

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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. * A sealed hierarchy describing the onboarding state for the for you screen.

@ -16,14 +16,14 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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.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.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -34,7 +34,6 @@ import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMoni
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -58,7 +57,6 @@ class ForYouViewModelTest {
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )

@ -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.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel 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.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.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent

@ -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.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase 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.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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow

@ -27,7 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp 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 @Composable
fun TopicsTabContent( fun TopicsTabContent(

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase 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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository

@ -59,7 +59,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -79,7 +79,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -104,7 +104,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }
@ -127,7 +127,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
) )
} }

@ -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.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.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.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.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
@ -79,7 +79,7 @@ internal fun TopicRoute(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle, onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews, onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
} }
@ -93,7 +93,7 @@ internal fun TopicScreen(
onFollowClick: (Boolean) -> Unit, onFollowClick: (Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
@ -129,7 +129,7 @@ internal fun TopicScreen(
news = newsUiState, news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl, imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged, onBookmarkChanged = onBookmarkChanged,
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
} }
@ -146,7 +146,7 @@ private fun LazyListScope.TopicBody(
news: NewsUiState, news: NewsUiState,
imageUrl: String, imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
// TODO: Show icon if available // TODO: Show icon if available
@ -154,7 +154,7 @@ private fun LazyListScope.TopicBody(
TopicHeader(name, description, imageUrl) TopicHeader(name, description, imageUrl)
} }
userNewsResourceCards(news, onBookmarkChanged, onNewsResourcesViewedChanged, onTopicClick) userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick)
} }
@Composable @Composable
@ -185,7 +185,7 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
private fun LazyListScope.userNewsResourceCards( private fun LazyListScope.userNewsResourceCards(
news: NewsUiState, news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
when (news) { when (news) {
@ -193,7 +193,7 @@ private fun LazyListScope.userNewsResourceCards(
userNewsResourceCardItems( userNewsResourceCardItems(
items = news.news, items = news.news,
onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
itemModifier = Modifier.padding(24.dp), itemModifier = Modifier.padding(24.dp),
) )
@ -220,7 +220,7 @@ private fun TopicBodyPreview() {
news = NewsUiState.Success(emptyList()), news = NewsUiState.Success(emptyList()),
imageUrl = "", imageUrl = "",
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -278,7 +278,7 @@ fun TopicScreenPopulated(
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -296,7 +296,7 @@ fun TopicScreenLoading() {
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> }, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -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.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository 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.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.decoder.StringDecoder
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.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs
@ -87,9 +87,9 @@ class TopicViewModel @Inject constructor(
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
} }
} }
} }

@ -17,8 +17,8 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
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.domain.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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video 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.Topic
@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@ -55,7 +54,6 @@ class TopicViewModelTest {
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )

Loading…
Cancel
Save