Display unread state on the news feed and bottom nav bar

When a news resource is unread, display a dot on its card in the news
feed.  When the For You section has unread resources, display a dot on
its icon in the navigation bar.

Update the read status when a resource is opened.
pull/595/head
James Rose 2 years ago
parent bd450099fb
commit ebfbb5bafd

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

@ -26,10 +26,15 @@ 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.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -63,6 +68,12 @@ class NavigationUiTest {
@get:Rule(order = 2) @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@ -81,6 +92,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -100,6 +112,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -119,6 +132,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -138,6 +152,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -157,6 +172,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -176,6 +192,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -195,6 +212,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -214,6 +232,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -233,6 +252,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }

@ -42,6 +42,7 @@ 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.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
@ -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,12 +44,15 @@ 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
@ -67,6 +70,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -85,6 +89,7 @@ fun NiaApp(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
), ),
userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
@ -128,8 +133,17 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
val forYouNewsResources by userNewsResourceRepository.getUserNewsResourcesForFollowedTopics()
.collectAsStateWithLifecycle(emptyList())
val unreadDestinations =
when {
forYouNewsResources.all { it.isViewed } -> emptySet()
else -> setOf(TopLevelDestination.FOR_YOU)
}
NiaBottomBar( NiaBottomBar(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"), modifier = Modifier.testTag("NiaBottomBar"),
@ -211,6 +225,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 +233,7 @@ private fun NiaNavRail(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -226,6 +242,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 +251,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,6 +275,25 @@ private fun NiaBottomBar(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
Modifier.drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
}
} else {
Modifier
},
) )
} }
} }

@ -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,
isViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},
@ -67,6 +69,7 @@ class NewsResourceCardTest {
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType, userNewsResource = newsWithUnknownResourceType,
isBookmarked = false, isBookmarked = false,
isViewed = 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,
isViewed = 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,
isViewed = true,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.getString(
R.string.unread_resource_dot_content_description,
),
)
.assertDoesNotExist()
}
} }

@ -48,6 +48,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
fun LazyGridScope.newsFeed( fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
when (feedState) { when (feedState) {
@ -70,7 +71,9 @@ fun LazyGridScope.newsFeed(
newsResourceTitle = userNewsResource.title, newsResourceTitle = userNewsResource.title,
) )
launchCustomChromeTab(context, resourceUrl, backgroundColor) launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourcesViewedChanged(userNewsResource.id, true)
}, },
isViewed = userNewsResource.isViewed,
onToggleBookmark = { onToggleBookmark = {
onNewsResourcesCheckedChanged( onNewsResourcesCheckedChanged(
userNewsResource.id, userNewsResource.id,
@ -122,6 +125,7 @@ private fun NewsFeedLoadingPreview() {
newsFeed( newsFeed(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -140,6 +144,7 @@ private fun NewsFeedContentPreview(
newsFeed( newsFeed(
feedState = NewsFeedUiState.Success(userNewsResources), feedState = NewsFeedUiState.Success(userNewsResources),
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
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
@ -40,7 +42,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
@ -77,6 +81,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR
fun NewsResourceCardExpanded( fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource, userNewsResource: UserNewsResource,
isBookmarked: Boolean, isBookmarked: Boolean,
isViewed: Boolean,
onToggleBookmark: () -> Unit, onToggleBookmark: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
@ -113,7 +118,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 (!isViewed) {
Dot(
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))
@ -181,6 +195,24 @@ fun BookmarkButton(
) )
} }
@Composable
fun Dot(
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()) }
@ -301,6 +333,7 @@ private fun ExpandedNewsResourcePreview(
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResources[0], userNewsResource = userNewsResources[0],
isBookmarked = true, isBookmarked = true,
isViewed = false,
onToggleBookmark = {}, onToggleBookmark = {},
onClick = {}, onClick = {},
onTopicClick = {}, onTopicClick = {},

@ -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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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,
isViewed = userNewsResource.isViewed,
onToggleBookmark = { onToggleBookmark(userNewsResource) }, onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
@ -59,7 +61,10 @@ fun LazyListScope.userNewsResourceCardItems(
newsResourceTitle = userNewsResource.title, newsResourceTitle = userNewsResource.title,
) )
when (onItemClick) { when (onItemClick) {
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) null -> {
launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourcesViewedChanged(userNewsResource.id, true)
}
else -> onItemClick(userNewsResource) else -> onItemClick(userNewsResource)
} }
}, },

@ -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 = {},
onNewsResourcesViewedChanged = { _, _ -> },
) )
} }
@ -71,6 +72,7 @@ class BookmarksScreenTest {
), ),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> },
) )
} }
@ -113,6 +115,7 @@ class BookmarksScreenTest {
removeFromBookmarksCalled = true removeFromBookmarksCalled = true
}, },
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> },
) )
} }
@ -143,6 +146,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourcesViewedChanged = { _, _ -> },
) )
} }

@ -73,6 +73,7 @@ internal fun BookmarksRoute(
BookmarksScreen( BookmarksScreen(
feedState = feedState, feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed,
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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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, onNewsResourcesViewedChanged, 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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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) },
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged,
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 = {},
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -55,4 +55,10 @@ class BookmarksViewModel @Inject constructor(
userDataRepository.updateNewsResourceBookmark(newsResourceId, false) userDataRepository.updateNewsResourceBookmark(newsResourceId, false)
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed)
}
}
} }

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

@ -107,6 +107,7 @@ internal fun ForYouRoute(
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed,
modifier = modifier, modifier = modifier,
) )
} }
@ -120,6 +121,7 @@ internal fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourcesViewedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading
@ -177,6 +179,7 @@ internal fun ForYouScreen(
newsFeed( newsFeed(
feedState = feedState, feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
) )
@ -413,6 +416,7 @@ fun ForYouScreenPopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -436,6 +440,7 @@ fun ForYouScreenOfflinePopulatedFeed(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -461,6 +466,7 @@ fun ForYouScreenTopicSelection(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -479,6 +485,7 @@ fun ForYouScreenLoading() {
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -502,6 +509,7 @@ fun ForYouScreenPopulatedAndLoading(
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -95,6 +95,12 @@ class ForYouViewModel @Inject constructor(
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed)
}
}
fun dismissOnboarding() { fun dismissOnboarding() {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setShouldHideOnboarding(true) userDataRepository.setShouldHideOnboarding(true)

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

@ -79,6 +79,7 @@ internal fun TopicRoute(
onBackClick = onBackClick, onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle, onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews, onBookmarkChanged = viewModel::bookmarkNews,
onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed,
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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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,
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged,
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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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, onNewsResourcesViewedChanged, 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,
onNewsResourcesViewedChanged: (String, Boolean) -> 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) },
onNewsResourcesViewedChanged = onNewsResourcesViewedChanged,
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 = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -271,6 +278,7 @@ fun TopicScreenPopulated(
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }
@ -288,6 +296,7 @@ fun TopicScreenLoading() {
onBackClick = {}, onBackClick = {},
onFollowClick = {}, onFollowClick = {},
onBookmarkChanged = { _, _ -> }, onBookmarkChanged = { _, _ -> },
onNewsResourcesViewedChanged = { _, _ -> },
onTopicClick = {}, onTopicClick = {},
) )
} }

@ -86,6 +86,12 @@ class TopicViewModel @Inject constructor(
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
} }
} }
fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed)
}
}
} }
private fun topicUiState( private fun topicUiState(

Loading…
Cancel
Save