Merge pull request #595 from android/jr/track-viewed

Add visual indicator of read/unread news resources
pull/674/head
James Rose 2 years ago committed by GitHub
commit 3e66e3d7e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -25,7 +25,10 @@ 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.testing.repository.TestNewsRepository
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
@ -63,6 +66,11 @@ class NavigationUiTest {
@get:Rule(order = 2) @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@ -81,6 +89,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -100,6 +109,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -119,6 +129,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -138,6 +149,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -157,6 +169,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -176,6 +189,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -195,6 +209,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -214,6 +229,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -233,6 +249,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }

@ -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,6 +40,7 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.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.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -67,6 +68,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var analyticsHelper: AnalyticsHelper lateinit var analyticsHelper: AnalyticsHelper
@Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -119,6 +123,7 @@ class MainActivity : ComponentActivity() {
NiaApp( NiaApp(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this), windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }

@ -44,16 +44,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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
@ -81,9 +85,11 @@ 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,
), ),
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
@ -128,8 +134,10 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
NiaBottomBar( NiaBottomBar(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"), modifier = Modifier.testTag("NiaBottomBar"),
@ -211,6 +219,7 @@ private fun NiaNavRail(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null, contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null, contentDescription = null,
@ -218,6 +227,7 @@ private fun NiaNavRail(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -226,6 +236,7 @@ private fun NiaNavRail(
@Composable @Composable
private fun NiaBottomBar( private fun NiaBottomBar(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?, currentDestination: NavDestination?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -234,6 +245,7 @@ private fun NiaBottomBar(
modifier = modifier, modifier = modifier,
) { ) {
destinations.forEach { destination -> destinations.forEach { destination ->
val hasUnread = destinationsWithUnreadResources.contains(destination)
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem( NiaNavigationBarItem(
selected = selected, selected = selected,
@ -257,11 +269,31 @@ private fun NiaBottomBar(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) notificationDot() else Modifier,
) )
} }
} }
} }
@Composable
private fun notificationDot(): Modifier {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
return Modifier.drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = 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.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet(),
)
/** /**
* UI logic for navigating to a top level destination in the app. Top level destinations have * 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

@ -24,5 +24,6 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
annotation class Dispatcher(val niaDispatcher: NiaDispatchers) annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
enum class NiaDispatchers { enum class NiaDispatchers {
Default,
IO, IO,
} }

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.di package com.google.samples.apps.nowinandroid.core.network.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -31,4 +32,8 @@ object DispatchersModule {
@Provides @Provides
@Dispatcher(IO) @Dispatcher(IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
} }

@ -0,0 +1,33 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface UserNewsResourceRepositoryModule {
@Binds
fun bindsUserNewsResourceRepository(
userDataRepository: CompositeUserNewsResourceRepository,
): UserNewsResourceRepository
}

@ -0,0 +1,69 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
* [UserDataRepository].
*/
class CompositeUserNewsResourceRepository @Inject constructor(
val newsRepository: NewsRepository,
val userDataRepository: UserDataRepository,
) : UserNewsResourceRepository {
/**
* Returns available news resources (joined with user data) matching the given query.
*/
override fun observeAll(
query: NewsResourceQuery,
): Flow<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 observeAllForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()
.flatMapLatest { followedTopics ->
when {
followedTopics.isEmpty() -> flowOf(emptyList())
else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics))
}
}
override fun observeAllBookmarked(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged()
.flatMapLatest { bookmarkedNewsResources ->
when {
bookmarkedNewsResources.isEmpty() -> flowOf(emptyList())
else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources))
}
}
}

@ -50,6 +50,9 @@ class OfflineFirstUserDataRepository @Inject constructor(
) )
} }
override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =
niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)
override suspend fun setThemeBrand(themeBrand: ThemeBrand) { override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand) niaPreferencesDataSource.setThemeBrand(themeBrand)
analyticsHelper.logThemeChanged(themeBrand.name) analyticsHelper.logThemeChanged(themeBrand.name)

@ -43,6 +43,11 @@ interface UserDataRepository {
*/ */
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
/**
* Updates the viewed status for a news resource
*/
suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean)
/** /**
* Sets the desired theme brand. * Sets the desired theme brand.
*/ */

@ -0,0 +1,45 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.coroutines.flow.Flow
/**
* Data layer implementation for [UserNewsResource]
*/
interface UserNewsResourceRepository {
/**
* Returns available news resources as a stream.
*/
fun observeAll(
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): Flow<List<UserNewsResource>>
/**
* Returns available news resources for the user's followed topics as a stream.
*/
fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>>
/**
* Returns the user's bookmarked news resources as a stream.
*/
fun observeAllBookmarked(): Flow<List<UserNewsResource>>
}

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

@ -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,38 +14,37 @@
* 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.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 com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class GetUserNewsResourcesUseCaseTest { class CompositeUserNewsResourceRepositoryTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository) private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)
@Test @Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest { fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream. // Obtain the user news resources flow.
val userNewsResources = useCase() val userNewsResources = userNewsResourceRepository.observeAll()
// Send some news resources and user data into the data repositories. // Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -68,11 +67,14 @@ class GetUserNewsResourcesUseCaseTest {
@Test @Test
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 = useCase( val userNewsResources =
NewsResourceQuery( userNewsResourceRepository.observeAll(
filterTopicIds = setOf(sampleTopic1.id), NewsResourceQuery(
), filterTopicIds = setOf(
) sampleTopic1.id,
),
),
)
// Send test data into the repositories. // Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -86,6 +88,51 @@ class GetUserNewsResourcesUseCaseTest {
userNewsResources.first(), userNewsResources.first(),
) )
} }
@Test
fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id.
val userNewsResources =
userNewsResourceRepository.observeAllForFollowedTopics()
// Send test data into the repositories.
val userData = emptyUserData.copy(
followedTopics = setOf(sampleTopic1.id),
)
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(userData)
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(userData),
userNewsResources.first(),
)
}
@Test
fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest {
// Obtain the bookmarked user news resources flow.
val userNewsResources = userNewsResourceRepository.observeAllBookmarked()
// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
// Construct the test user data with bookmarks and followed topics.
val userData = emptyUserData.copy(
bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
followedTopics = setOf(sampleTopic1.id),
)
userDataRepository.setUserData(userData)
// Check that the correct news resources are returned with their bookmarked state.
assertEquals(
listOf(sampleNewsResources[0], sampleNewsResources[2]).mapToUserNewsResources(userData),
userNewsResources.first(),
)
}
} }
private val sampleTopic1 = Topic( 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
@ -68,6 +68,7 @@ class UserNewsResourceTest {
val userData = UserData( val userData = UserData(
bookmarkedNewsResources = setOf("N1"), bookmarkedNewsResources = setOf("N1"),
viewedNewsResources = setOf("N1"),
followedTopics = setOf("T1"), followedTopics = setOf("T1"),
themeBrand = DEFAULT, themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM, darkThemeConfig = FOLLOW_SYSTEM,

@ -66,6 +66,7 @@ class OfflineFirstUserDataRepositoryTest {
assertEquals( assertEquals(
UserData( UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
viewedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
@ -160,6 +161,37 @@ class OfflineFirstUserDataRepositoryTest {
) )
} }
@Test
fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() =
runTest {
subject.setNewsResourceViewed(newsResourceId = "0", viewed = true)
assertEquals(
setOf("0"),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
subject.setNewsResourceViewed(newsResourceId = "1", viewed = true)
assertEquals(
setOf("0", "1"),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
assertEquals(
niaPreferencesDataSource.userData
.map { it.viewedNewsResources }
.first(),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
}
@Test @Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =
testScope.runTest { testScope.runTest {

@ -33,6 +33,7 @@ class NiaPreferencesDataSource @Inject constructor(
.map { .map {
UserData( UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
viewedNewsResources = it.viewedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys,
themeBrand = when (it.themeBrand) { themeBrand = when (it.themeBrand) {
null, null,
@ -137,6 +138,18 @@ class NiaPreferencesDataSource @Inject constructor(
} }
} }
suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
userPreferences.updateData {
it.copy {
if (viewed) {
viewedNewsResourceIds.put(newsResourceId, true)
} else {
viewedNewsResourceIds.remove(newsResourceId)
}
}
}
}
suspend fun getChangeListVersions() = userPreferences.data suspend fun getChangeListVersions() = userPreferences.data
.map { .map {
ChangeListVersions( ChangeListVersions(

@ -40,6 +40,7 @@ message UserPreferences {
map<string, bool> followed_topic_ids = 13; map<string, bool> followed_topic_ids = 13;
map<string, bool> followed_author_ids = 14; map<string, bool> followed_author_ids = 14;
map<string, bool> bookmarked_news_resource_ids = 15; map<string, bool> bookmarked_news_resource_ids = 15;
map<string, bool> viewed_news_resource_ids = 20;
ThemeBrandProto theme_brand = 16; ThemeBrandProto theme_brand = 16;
DarkThemeConfigProto dark_theme_config = 17; DarkThemeConfigProto dark_theme_config = 17;
@ -47,4 +48,6 @@ message UserPreferences {
bool should_hide_onboarding = 18; bool should_hide_onboarding = 18;
bool use_dynamic_color = 19; bool use_dynamic_color = 19;
// NEXT AVAILABLE ID: 21
} }

@ -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,58 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import javax.inject.Inject
/**
* A use case responsible for obtaining news resources with their associated bookmarked (also known
* as "saved") state.
*/
class GetUserNewsResourcesUseCase @Inject constructor(
private val newsRepository: NewsRepository,
private val userDataRepository: UserDataRepository,
) {
/**
* Returns a list of UserNewsResources which match the supplied set of topic ids.
*
* @param query - Summary of query parameters for news resources.
*/
operator fun invoke(
query: NewsResourceQuery = NewsResourceQuery(),
): Flow<List<UserNewsResource>> =
newsRepository.getNewsResources(
query = query,
).mapToUserNewsResources(userDataRepository.userData)
}
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.

@ -21,6 +21,7 @@ package com.google.samples.apps.nowinandroid.core.model.data
*/ */
data class UserData( data class UserData(
val bookmarkedNewsResources: Set<String>, val bookmarkedNewsResources: Set<String>,
val viewedNewsResources: Set<String>,
val followedTopics: Set<String>, val followedTopics: Set<String>,
val themeBrand: ThemeBrand, val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig, val darkThemeConfig: DarkThemeConfig,

@ -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,6 +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 hasBeenViewed: Boolean,
) { ) {
constructor(newsResource: NewsResource, userData: UserData) : this( constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id, id = newsResource.id,
@ -51,6 +49,7 @@ data class UserNewsResource internal constructor(
) )
}, },
isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), isSaved = userData.bookmarkedNewsResources.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
@ -30,6 +32,7 @@ import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */ /* ktlint-disable max-line-length */
val userNewsResourcesTestData: List<UserNewsResource> = UserData( val userNewsResourcesTestData: List<UserNewsResource> = UserData(
bookmarkedNewsResources = setOf("1", "4"), bookmarkedNewsResources = setOf("1", "4"),
viewedNewsResources = setOf("1", "2", "4"),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.ANDROID, themeBrand = ThemeBrand.ANDROID,
darkThemeConfig = DarkThemeConfig.DARK, darkThemeConfig = DarkThemeConfig.DARK,
@ -53,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,
@ -69,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,
@ -85,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,
@ -99,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,

@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.filterNotNull
val emptyUserData = UserData( val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
viewedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
@ -72,6 +73,21 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
currentUserData.let { current ->
_userData.tryEmit(
current.copy(
viewedNewsResources =
if (viewed) {
current.viewedNewsResources + newsResourceId
} else {
current.viewedNewsResources - newsResourceId
},
),
)
}
}
override suspend fun setThemeBrand(themeBrand: ThemeBrand) { override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
currentUserData.let { current -> currentUserData.let { current ->
_userData.tryEmit(current.copy(themeBrand = themeBrand)) _userData.tryEmit(current.copy(themeBrand = themeBrand))

@ -20,6 +20,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
@ -39,6 +40,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithKnownResourceType, userNewsResource = newsWithKnownResourceType,
isBookmarked = false, isBookmarked = false,
hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -67,6 +69,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType, userNewsResource = newsWithUnknownResourceType,
isBookmarked = false, isBookmarked = false,
hasBeenViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -101,4 +104,52 @@ class NewsResourceCardTest {
.assertContentDescriptionEquals(expectedContentDescription) .assertContentDescriptionEquals(expectedContentDescription)
} }
} }
@Test
fun testUnreadDot_displayedWhenUnread() {
val unreadNews = userNewsResourcesTestData[2]
composeTestRule.setContent {
NewsResourceCardExpanded(
userNewsResource = unreadNews,
isBookmarked = false,
hasBeenViewed = false,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.getString(
R.string.unread_resource_dot_content_description,
),
)
.assertIsDisplayed()
}
@Test
fun testUnreadDot_notDisplayedWhenRead() {
val readNews = userNewsResourcesTestData[0]
composeTestRule.setContent {
NewsResourceCardExpanded(
userNewsResource = readNews,
isBookmarked = false,
hasBeenViewed = true,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.getString(
R.string.unread_resource_dot_content_description,
),
)
.assertDoesNotExist()
}
} }

@ -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 */

@ -41,7 +41,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.
@ -50,6 +50,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,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
when (feedState) { when (feedState) {
@ -71,7 +72,9 @@ fun LazyGridScope.newsFeed(
newsResourceId = userNewsResource.id, newsResourceId = userNewsResource.id,
) )
launchCustomChromeTab(context, resourceUrl, backgroundColor) launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourceViewed(userNewsResource.id)
}, },
hasBeenViewed = userNewsResource.hasBeenViewed,
onToggleBookmark = { onToggleBookmark = {
onNewsResourcesCheckedChanged( onNewsResourcesCheckedChanged(
userNewsResource.id, userNewsResource.id,
@ -124,6 +127,7 @@ private fun NewsFeedLoadingPreview() {
newsFeed( newsFeed(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -142,6 +146,7 @@ private fun NewsFeedContentPreview(
newsFeed( newsFeed(
feedState = NewsFeedUiState.Success(userNewsResources), feedState = NewsFeedUiState.Success(userNewsResources),
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.ui package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -41,7 +43,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@ -58,10 +62,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag import com.google.samples.apps.nowinandroid.core.designsystem.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
@ -78,6 +82,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR
fun NewsResourceCardExpanded( fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource, userNewsResource: UserNewsResource,
isBookmarked: Boolean, isBookmarked: Boolean,
hasBeenViewed: Boolean,
onToggleBookmark: () -> Unit, onToggleBookmark: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
@ -114,7 +119,16 @@ fun NewsResourceCardExpanded(
BookmarkButton(isBookmarked, onToggleBookmark) BookmarkButton(isBookmarked, onToggleBookmark)
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) Row(verticalAlignment = Alignment.CenterVertically) {
if (!hasBeenViewed) {
NotificationDot(
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(8.dp),
)
Spacer(modifier = Modifier.size(6.dp))
}
NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
NewsResourceShortDescription(userNewsResource.content) NewsResourceShortDescription(userNewsResource.content)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -182,6 +196,24 @@ fun BookmarkButton(
) )
} }
@Composable
fun NotificationDot(
color: Color,
modifier: Modifier = Modifier,
) {
val description = stringResource(R.string.unread_resource_dot_content_description)
Canvas(
modifier = modifier
.semantics { contentDescription = description },
onDraw = {
drawCircle(
color,
radius = size.minDimension / 2,
)
},
)
}
@Composable @Composable
fun dateFormatted(publishDate: Instant): String { fun dateFormatted(publishDate: Instant): String {
var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) } var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) }
@ -305,6 +337,7 @@ private fun ExpandedNewsResourcePreview(
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResources[0], userNewsResource = userNewsResources[0],
isBookmarked = true, isBookmarked = true,
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,6 +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,
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,
@ -52,6 +53,7 @@ fun LazyListScope.userNewsResourceCardItems(
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResource, userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved, isBookmarked = userNewsResource.isSaved,
hasBeenViewed = userNewsResource.hasBeenViewed,
onToggleBookmark = { onToggleBookmark(userNewsResource) }, onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
@ -61,6 +63,7 @@ fun LazyListScope.userNewsResourceCardItems(
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)
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
@ -40,6 +40,7 @@ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider<List<U
get() { get() {
val userData: UserData = UserData( val userData: UserData = UserData(
bookmarkedNewsResources = setOf("1", "3"), bookmarkedNewsResources = setOf("1", "3"),
viewedNewsResources = setOf("1", "2", "4"),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.ANDROID, themeBrand = ThemeBrand.ANDROID,
darkThemeConfig = DarkThemeConfig.DARK, darkThemeConfig = DarkThemeConfig.DARK,

@ -19,6 +19,8 @@
<string name="unbookmark">Unbookmark</string> <string name="unbookmark">Unbookmark</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="unread_resource_dot_content_description">Unread</string>
<string name="card_tap_action">Open Resource Link</string> <string name="card_tap_action">Open Resource Link</string>
<string name="card_meta_data_text">%1$s • %2$s</string> <string name="card_meta_data_text">%1$s • %2$s</string>

@ -52,6 +52,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {},
) )
} }
@ -71,6 +72,7 @@ class BookmarksScreenTest {
), ),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {},
) )
} }
@ -113,6 +115,7 @@ class BookmarksScreenTest {
removeFromBookmarksCalled = true removeFromBookmarksCalled = true
}, },
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {},
) )
} }
@ -143,6 +146,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
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,6 +73,7 @@ internal fun BookmarksRoute(
BookmarksScreen( BookmarksScreen(
feedState = feedState, feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
modifier = modifier, modifier = modifier,
) )
@ -86,13 +87,14 @@ internal fun BookmarksRoute(
internal fun BookmarksScreen( internal fun BookmarksScreen(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> 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, onTopicClick, modifier) BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier)
} else { } else {
EmptyState(modifier) EmptyState(modifier)
} }
@ -115,6 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
private fun BookmarksGrid( private fun BookmarksGrid(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -133,6 +136,7 @@ private fun BookmarksGrid(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@ -198,6 +202,7 @@ private fun BookmarksGridPreview(
BookmarksGrid( BookmarksGrid(
feedState = Success(userNewsResources), feedState = Success(userNewsResources),
removeFromBookmarks = {}, removeFromBookmarks = {},
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.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
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 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
@ -36,23 +35,28 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources() val feedUiState: StateFlow<NewsFeedUiState> =
.filterNot { it.isEmpty() } userNewsResourceRepository.observeAllBookmarked()
.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 {
userDataRepository.updateNewsResourceBookmark(newsResourceId, false) userDataRepository.updateNewsResourceBookmark(newsResourceId, false)
} }
} }
fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch {
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.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.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
@ -43,7 +43,7 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
@ -53,7 +53,7 @@ class BookmarksViewModelTest {
fun setup() { fun setup() {
viewModel = BookmarksViewModel( viewModel = BookmarksViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase, userNewsResourceRepository = userNewsResourceRepository,
) )
} }

@ -56,6 +56,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -79,6 +80,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -108,6 +110,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -152,6 +155,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -189,6 +193,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -212,6 +217,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
} }
@ -236,6 +242,7 @@ class ForYouScreenTest {
onTopicClick = {}, onTopicClick = {},
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }

@ -79,7 +79,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.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
@ -105,6 +105,7 @@ internal fun ForYouRoute(
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
modifier = modifier, modifier = modifier,
) )
} }
@ -118,6 +119,7 @@ internal fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading
@ -160,6 +162,7 @@ internal fun ForYouScreen(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
@ -397,6 +400,7 @@ fun ForYouScreenPopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -420,6 +424,7 @@ fun ForYouScreenOfflinePopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -446,6 +451,7 @@ fun ForYouScreenTopicSelection(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -464,6 +470,7 @@ fun ForYouScreenLoading() {
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -487,6 +494,7 @@ fun ForYouScreenPopulatedAndLoading(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
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.SyncManager import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import 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
@ -43,7 +37,7 @@ import javax.inject.Inject
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
syncManager: SyncManager, syncManager: SyncManager,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
getUserNewsResources: GetUserNewsResourcesUseCase, userNewsResourceRepository: UserNewsResourceRepository,
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() { ) : ViewModel() {
@ -58,7 +52,7 @@ class ForYouViewModel @Inject constructor(
) )
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
userDataRepository.getFollowedUserNewsResources(getUserNewsResources) userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success) .map(NewsFeedUiState::Success)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -95,55 +89,15 @@ class ForYouViewModel @Inject constructor(
} }
} }
fun dismissOnboarding() { fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setShouldHideOnboarding(true) userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
} }
} }
}
/** fun dismissOnboarding() {
* Obtain a flow of user news resources whose topics match those the user is following. viewModelScope.launch {
* userDataRepository.setShouldHideOnboarding(true)
* getUserNewsResources: The `UseCase` used to obtain the flow of user news resources.
*/
private fun UserDataRepository.getFollowedUserNewsResources(
getUserNewsResources: GetUserNewsResourcesUseCase,
): 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 {
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.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.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
@ -56,7 +56,7 @@ class ForYouViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
@ -72,7 +72,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
syncManager = syncManager, syncManager = syncManager,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getUserNewsResources = getUserNewsResourcesUseCase, userNewsResourceRepository = userNewsResourceRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
) )
} }

@ -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,6 +59,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
@ -78,6 +79,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
@ -102,6 +104,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
) )
} }
@ -124,6 +127,7 @@ class TopicScreenTest {
onFollowClick = {}, onFollowClick = {},
onTopicClick = {}, onTopicClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
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,6 +79,7 @@ internal fun TopicRoute(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle, onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews, onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
} }
@ -92,6 +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,
onNewsResourceViewed: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
@ -127,6 +129,7 @@ internal fun TopicScreen(
news = newsUiState, news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl, imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged, onBookmarkChanged = onBookmarkChanged,
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
} }
@ -143,6 +146,7 @@ private fun LazyListScope.TopicBody(
news: NewsUiState, news: NewsUiState,
imageUrl: String, imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
// TODO: Show icon if available // TODO: Show icon if available
@ -150,7 +154,7 @@ private fun LazyListScope.TopicBody(
TopicHeader(name, description, imageUrl) TopicHeader(name, description, imageUrl)
} }
userNewsResourceCards(news, onBookmarkChanged, onTopicClick) userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick)
} }
@Composable @Composable
@ -181,6 +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,
onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
when (news) { when (news) {
@ -188,6 +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) },
onNewsResourceViewed = onNewsResourceViewed,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
itemModifier = Modifier.padding(24.dp), itemModifier = Modifier.padding(24.dp),
) )
@ -214,6 +220,7 @@ private fun TopicBodyPreview() {
news = NewsUiState.Success(emptyList()), news = NewsUiState.Success(emptyList()),
imageUrl = "", imageUrl = "",
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -271,6 +278,7 @@ fun TopicScreenPopulated(
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -288,6 +296,7 @@ fun TopicScreenLoading() {
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
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.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
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.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
@ -46,7 +46,7 @@ class TopicViewModel @Inject constructor(
stringDecoder: StringDecoder, stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository, topicsRepository: TopicsRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
@ -67,7 +67,7 @@ class TopicViewModel @Inject constructor(
val newUiState: StateFlow<NewsUiState> = newsUiState( val newUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId, topicId = topicArgs.topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResources, userNewsResourceRepository = userNewsResourceRepository,
) )
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -86,6 +86,12 @@ class TopicViewModel @Inject constructor(
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
} }
} }
fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
viewModelScope.launch {
userDataRepository.setNewsResourceViewed(newsResourceId, viewed)
}
}
} }
private fun topicUiState( private fun topicUiState(
@ -135,14 +141,12 @@ private fun topicUiState(
private fun newsUiState( private fun newsUiState(
topicId: String, topicId: String,
getSaveableNewsResources: GetUserNewsResourcesUseCase, userNewsResourceRepository: UserNewsResourceRepository,
userDataRepository: UserDataRepository, userDataRepository: UserDataRepository,
): Flow<NewsUiState> { ): Flow<NewsUiState> {
// Observe news // Observe news
val newsStream: Flow<List<UserNewsResource>> = getSaveableNewsResources( val newsStream: Flow<List<UserNewsResource>> = userNewsResourceRepository.observeAll(
NewsResourceQuery( NewsResourceQuery(filterTopicIds = setOf(element = topicId)),
filterTopicIds = setOf(element = topicId),
),
) )
// Observe bookmarks // Observe bookmarks

@ -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.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
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.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
@ -53,7 +53,7 @@ class TopicViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
@ -66,7 +66,7 @@ class TopicViewModelTest {
stringDecoder = FakeStringDecoder(), stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
getSaveableNewsResources = getUserNewsResourcesUseCase, userNewsResourceRepository = userNewsResourceRepository,
) )
} }

Loading…
Cancel
Save