Merge remote-tracking branch 'github/main' into feb9automerger

* github/main:
  Bump version to 4
  Remove unused DropdownMenu component
  Route topic chip tap events up to NiaNavHost
  Apply suggestions from code review
  Migrate custom room configuration into a convention plugin
  Update docs/ArchitectureLearningJourney.md - User action follow topic
  Update docs/ArchitectureLearningJourney.md - Writing data

Change-Id: I417abcaba792bbf297a8156bb1c4cf677dcb8bea
pull/591/head^2
Don Turner 2 years ago
commit 4284e6912e

@ -38,7 +38,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaDropdownMenuButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
@ -167,23 +166,6 @@ fun NiaCatalog() {
}
}
item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaDropdownMenuButton(
text = { Text("Enabled") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) },
)
NiaDropdownMenuButton(
text = { Text("Disabled") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) },
enabled = false,
)
}
}
item { Text("Chips", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
@ -315,45 +297,19 @@ fun NiaCatalog() {
item { Text("Tags", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
var expandedTopicId by remember { mutableStateOf<String?>(null) }
var firstFollowed by remember { mutableStateOf(false) }
NiaTopicTag(
expanded = expandedTopicId == "Topic 1",
followed = firstFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
onUnfollowClick = { firstFollowed = false },
onBrowseClick = {},
followed = true,
onClick = {},
text = { Text(text = "Topic 1".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") },
)
var secondFollowed by remember { mutableStateOf(true) }
NiaTopicTag(
expanded = expandedTopicId == "Topic 2",
followed = secondFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
onUnfollowClick = { secondFollowed = false },
onBrowseClick = {},
followed = false,
onClick = {},
text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") },
)
NiaTopicTag(
expanded = false,
followed = false,
onDropdownMenuToggle = {},
onFollowClick = {},
onUnfollowClick = {},
onBrowseClick = {},
onClick = {},
text = { Text(text = "Disabled".uppercase()) },
enabled = false,
)

@ -29,8 +29,8 @@ plugins {
android {
defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 3
versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level
versionCode = 4
versionName = "0.0.4" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"

@ -37,7 +37,6 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
@Composable
fun NiaNavHost(
navController: NavHostController,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute,
) {
@ -46,14 +45,18 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen()
bookmarksScreen()
// TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {})
interestsGraph(
navigateToTopic = { topicId ->
onTopicClick = { topicId ->
navController.navigateToTopic(topicId)
},
nestedGraphs = {
topicScreen(onBackClick)
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = {},
)
},
)
}

@ -178,10 +178,7 @@ fun NiaApp(
)
}
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick,
)
NiaNavHost(appState.navController)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that

@ -136,10 +136,6 @@ class NiaAppState(
}
}
fun onBackClick() {
navController.popBackStack()
}
fun setShowSettingsDialog(shouldShow: Boolean) {
shouldShowSettingsDialog = shouldShow
}

@ -30,6 +30,7 @@ dependencies {
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.ksp.gradlePlugin)
}
gradlePlugin {
@ -70,6 +71,10 @@ gradlePlugin {
id = "nowinandroid.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("androidRoom") {
id = "nowinandroid.android.room"
implementationClass = "AndroidRoomConventionPlugin"
}
register("androidFirebase") {
id = "nowinandroid.android.application.firebase"
implementationClass = "AndroidApplicationFirebaseConventionPlugin"
@ -78,6 +83,5 @@ gradlePlugin {
id = "nowinandroid.android.application.flavors"
implementationClass = "AndroidApplicationFlavorsConventionPlugin"
}
}
}

@ -0,0 +1,63 @@
/*
* 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.
*/
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("implementation", libs.findLibrary("room.runtime").get())
add("implementation", libs.findLibrary("room.ktx").get())
add("ksp", libs.findLibrary("room.compiler").get())
}
}
}
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}

@ -33,5 +33,6 @@ plugins {
alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.gms) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.secrets) apply false
}

@ -22,18 +22,11 @@ plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.android.hilt")
alias(libs.plugins.ksp)
id("nowinandroid.android.room")
}
android {
defaultConfig {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
@ -57,10 +50,6 @@ android {
dependencies {
implementation(project(":core:model"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)

@ -1,218 +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.designsystem.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
/**
* Now in Android dropdown menu button with included trailing icon as well as text label and item
* content slots.
*
* @param items The list of items to display in the menu.
* @param onItemClick Called when the user clicks on a menu item.
* @param modifier Modifier to be applied to the button.
* @param enabled Controls the enabled state of the button. When `false`, this button will not be
* clickable and will appear disabled to accessibility services.
* @param dismissOnItemClick Whether the menu should be dismissed when an item is clicked.
* @param itemText The text label content for a given item.
* @param itemLeadingIcon The leading icon content for a given item.
* @param itemTrailingIcon The trailing icon content for a given item.
*/
@Composable
fun <T> NiaDropdownMenuButton(
items: List<T>,
onItemClick: (item: T) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
dismissOnItemClick: Boolean = true,
text: @Composable () -> Unit,
itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
),
border = BorderStroke(
width = NiaDropdownMenuDefaults.DropdownMenuButtonBorderWidth,
color = if (enabled) {
MaterialTheme.colorScheme.outline
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaDropdownMenuDefaults.DisabledDropdownMenuButtonBorderAlpha,
)
},
),
contentPadding = NiaDropdownMenuDefaults.DropdownMenuButtonContentPadding,
) {
NiaDropdownMenuButtonContent(
text = text,
trailingIcon = {
Icon(
imageVector = if (expanded) {
NiaIcons.ArrowDropUp
} else {
NiaIcons.ArrowDropDown
},
contentDescription = null,
)
},
)
}
NiaDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
items = items,
onItemClick = onItemClick,
dismissOnItemClick = dismissOnItemClick,
itemText = itemText,
itemLeadingIcon = itemLeadingIcon,
itemTrailingIcon = itemTrailingIcon,
)
}
}
/**
* Internal Now in Android dropdown menu button content layout for arranging the text label and
* trailing icon.
*
* @param text The button text label content.
* @param trailingIcon The button trailing icon content. Default is `null` for no trailing icon.
*/
@Composable
private fun NiaDropdownMenuButtonContent(
text: @Composable () -> Unit,
trailingIcon: @Composable (() -> Unit)? = null,
) {
Box(
Modifier
.padding(
end = if (trailingIcon != null) {
ButtonDefaults.IconSpacing
} else {
0.dp
},
),
) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text()
}
}
if (trailingIcon != null) {
Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) {
trailingIcon()
}
}
}
/**
* Now in Android dropdown menu with item content slots. Wraps Material 3 [DropdownMenu] and
* [DropdownMenuItem].
*
* @param expanded Whether the menu is currently open and visible to the user.
* @param onDismissRequest Called when the user requests to dismiss the menu, such as by
* tapping outside the menu's bounds.
* @param items The list of items to display in the menu.
* @param onItemClick Called when the user clicks on a menu item.
* @param dismissOnItemClick Whether the menu should be dismissed when an item is clicked.
* @param itemText The text label content for a given item.
* @param itemLeadingIcon The leading icon content for a given item.
* @param itemTrailingIcon The trailing icon content for a given item.
*/
@Composable
fun <T> NiaDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
items: List<T>,
onItemClick: (item: T) -> Unit,
dismissOnItemClick: Boolean = true,
itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
) {
items.forEach { item ->
DropdownMenuItem(
text = { itemText(item) },
onClick = {
onItemClick(item)
if (dismissOnItemClick) onDismissRequest()
},
leadingIcon = if (itemLeadingIcon != null) {
{ itemLeadingIcon(item) }
} else {
null
},
trailingIcon = if (itemTrailingIcon != null) {
{ itemTrailingIcon(item) }
} else {
null
},
)
}
}
}
/**
* Now in Android dropdown menu default values.
*/
object NiaDropdownMenuDefaults {
// TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default
const val DisabledDropdownMenuButtonBorderAlpha = 0.12f
// TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults
val DropdownMenuButtonBorderWidth = 1.dp
// TODO: File bug
// Various default button padding values aren't exposed via ButtonDefaults
val DropdownMenuButtonContentPadding =
PaddingValues(
start = 24.dp,
top = 8.dp,
end = 16.dp,
bottom = 8.dp,
)
}

@ -20,28 +20,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.google.samples.apps.nowinandroid.core.designsystem.R
@Composable
fun NiaTopicTag(
modifier: Modifier = Modifier,
expanded: Boolean = false,
followed: Boolean,
onDropdownMenuToggle: (show: Boolean) -> Unit = {},
onFollowClick: () -> Unit,
onUnfollowClick: () -> Unit,
onBrowseClick: () -> Unit,
onClick: () -> Unit,
enabled: Boolean = true,
text: @Composable () -> Unit,
followText: @Composable () -> Unit = { Text(stringResource(R.string.follow)) },
unFollowText: @Composable () -> Unit = { Text(stringResource(R.string.unfollow)) },
browseText: @Composable () -> Unit = { Text(stringResource(R.string.browse_topic)) },
) {
Box(modifier = modifier) {
val containerColor = if (followed) {
@ -52,7 +42,7 @@ fun NiaTopicTag(
)
}
TextButton(
onClick = { onDropdownMenuToggle(true) },
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.textButtonColors(
containerColor = containerColor,
@ -66,25 +56,6 @@ fun NiaTopicTag(
text()
}
}
NiaDropdownMenu(
expanded = expanded,
onDismissRequest = { onDropdownMenuToggle(false) },
items = if (followed) listOf(UNFOLLOW, BROWSE) else listOf(FOLLOW, BROWSE),
onItemClick = { item ->
when (item) {
FOLLOW -> onFollowClick()
UNFOLLOW -> onUnfollowClick()
BROWSE -> onBrowseClick()
}
},
itemText = { item ->
when (item) {
FOLLOW -> followText()
UNFOLLOW -> unFollowText()
BROWSE -> browseText()
}
},
)
}
}
@ -98,7 +69,3 @@ object NiaTagDefaults {
// Button disabled container alpha value not exposed by ButtonDefaults
const val DisabledTopicTagContainerAlpha = 0.12f
}
private const val FOLLOW = 1
private const val UNFOLLOW = 2
private const val BROWSE = 3

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<resources>
<string name="follow">Follow</string>
<string name="unfollow">Unfollow</string>
<string name="browse_topic">Browse topic</string>
</resources>

@ -41,6 +41,7 @@ class NewsResourceCardTest {
isBookmarked = false,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
dateFormatted = dateFormatted(publishDate = newsWithKnownResourceType.publishDate)
@ -68,6 +69,7 @@ class NewsResourceCardTest {
isBookmarked = false,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
dateFormatted = dateFormatted(publishDate = newsWithUnknownResourceType.publishDate)
@ -81,7 +83,10 @@ class NewsResourceCardTest {
@Test
fun testTopicsChipColorBackground_matchesFollowedState() {
composeTestRule.setContent {
NewsResourceTopics(topics = followableTopicTestData)
NewsResourceTopics(
topics = followableTopicTestData,
onTopicClick = {},
)
}
for (followableTopic in followableTopicTestData) {

@ -48,6 +48,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
fun LazyGridScope.newsFeed(
feedState: NewsFeedUiState,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
) {
when (feedState) {
NewsFeedUiState.Loading -> Unit
@ -76,6 +77,7 @@ fun LazyGridScope.newsFeed(
!userNewsResource.isSaved,
)
},
onTopicClick = onTopicClick,
)
}
}
@ -120,6 +122,7 @@ private fun NewsFeedLoadingPreview() {
newsFeed(
feedState = NewsFeedUiState.Loading,
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -137,6 +140,7 @@ private fun NewsFeedContentPreview(
newsFeed(
feedState = NewsFeedUiState.Success(userNewsResources),
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}

@ -79,6 +79,7 @@ fun NewsResourceCardExpanded(
isBookmarked: Boolean,
onToggleBookmark: () -> Unit,
onClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val clickActionLabel = stringResource(R.string.card_tap_action)
@ -116,7 +117,10 @@ fun NewsResourceCardExpanded(
Spacer(modifier = Modifier.height(12.dp))
NewsResourceShortDescription(userNewsResource.content)
Spacer(modifier = Modifier.height(12.dp))
NewsResourceTopics(userNewsResource.followableTopics)
NewsResourceTopics(
topics = userNewsResource.followableTopics,
onTopicClick = onTopicClick,
)
}
}
}
@ -231,26 +235,17 @@ fun NewsResourceShortDescription(
@Composable
fun NewsResourceTopics(
topics: List<FollowableTopic>,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// Store the ID of the Topic which has its "following" menu expanded, if any.
// To avoid UI confusion, only one topic can have an expanded menu at a time.
var expandedTopicId by remember { mutableStateOf<String?>(null) }
Row(
modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
for (followableTopic in topics) {
NiaTopicTag(
expanded = expandedTopicId == followableTopic.topic.id,
followed = followableTopic.isFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) followableTopic.topic.id else null
},
onFollowClick = { }, // ToDo
onUnfollowClick = { }, // ToDo
onBrowseClick = { }, // ToDo
onClick = { onTopicClick(followableTopic.topic.id) },
text = {
val contentDescription = if (followableTopic.isFollowed) {
stringResource(
@ -308,6 +303,7 @@ private fun ExpandedNewsResourcePreview(
isBookmarked = true,
onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
}
}

@ -38,6 +38,7 @@ fun LazyListScope.userNewsResourceCardItems(
items: List<UserNewsResource>,
onToggleBookmark: (item: UserNewsResource) -> Unit,
onItemClick: ((item: UserNewsResource) -> Unit)? = null,
onTopicClick: (String) -> Unit,
itemModifier: Modifier = Modifier,
) = items(
items = items,
@ -62,6 +63,7 @@ fun LazyListScope.userNewsResourceCardItems(
else -> onItemClick(userNewsResource)
}
},
onTopicClick = onTopicClick,
modifier = itemModifier,
)
},

@ -194,7 +194,7 @@ To write data, the repository provides suspend functions. It is up to the caller
_Example: Follow a topic_
Simply call `TopicsRepository.setFollowedTopicId` with the ID of the topic which the user wishes to follow.
Simply call `UserDataRepository.toggleFollowedTopicId` with the ID of the topic the user wishes to follow and `followed=true` to indicate that the topic should be followed (use `false` to unfollow a topic).
### Data sources
@ -309,7 +309,7 @@ User actions are communicated from UI elements to ViewModels using regular metho
**Example: Following a topic**
The `InterestsScreen` takes a lambda expression named `followTopic` which is supplied from `InterestsViewModel.followTopic`. Each time the user taps on a topic to follow this method is called. The ViewModel then processes this action by informing the topics repository.
The `InterestsScreen` takes a lambda expression named `followTopic` which is supplied from `InterestsViewModel.followTopic`. Each time the user taps on a topic to follow this method is called. The ViewModel then processes this action by informing the user data repository.
## Further reading

@ -50,7 +50,8 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Loading,
removeFromBookmarks = { },
removeFromBookmarks = {},
onTopicClick = {},
)
}
@ -68,7 +69,8 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success(
userNewsResourcesTestData.take(2),
),
removeFromBookmarks = { },
removeFromBookmarks = {},
onTopicClick = {},
)
}
@ -110,6 +112,7 @@ class BookmarksScreenTest {
assertEquals(userNewsResourcesTestData[0].id, newsResourceId)
removeFromBookmarksCalled = true
},
onTopicClick = {},
)
}
@ -138,7 +141,8 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
removeFromBookmarks = { },
removeFromBookmarks = {},
onTopicClick = {},
)
}

@ -67,6 +67,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun BookmarksRoute(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel(),
) {
@ -74,6 +75,7 @@ internal fun BookmarksRoute(
BookmarksScreen(
feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources,
onTopicClick = onTopicClick,
modifier = modifier,
)
}
@ -86,12 +88,13 @@ internal fun BookmarksRoute(
internal fun BookmarksScreen(
feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
when (feedState) {
Loading -> LoadingState(modifier)
is Success -> if (feedState.feed.isNotEmpty()) {
BookmarksGrid(feedState, removeFromBookmarks, modifier)
BookmarksGrid(feedState, removeFromBookmarks, onTopicClick, modifier)
} else {
EmptyState(modifier)
}
@ -114,6 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) {
private fun BookmarksGrid(
feedState: NewsFeedUiState,
removeFromBookmarks: (String) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollableState = rememberLazyGridState()
@ -131,6 +135,7 @@ private fun BookmarksGrid(
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) },
onTopicClick = onTopicClick,
)
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
@ -195,6 +200,7 @@ private fun BookmarksGridPreview(
BookmarksGrid(
feedState = Success(userNewsResources),
removeFromBookmarks = {},
onTopicClick = {},
)
}
}

@ -28,8 +28,8 @@ fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) {
this.navigate(bookmarksRoute, navOptions)
}
fun NavGraphBuilder.bookmarksScreen() {
fun NavGraphBuilder.bookmarksScreen(onTopicClick: (String) -> Unit) {
composable(route = bookmarksRoute) {
BookmarksRoute()
BookmarksRoute(onTopicClick)
}
}

@ -53,6 +53,7 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -75,6 +76,7 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -103,6 +105,7 @@ class ForYouScreenTest {
feed = emptyList(),
),
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -146,6 +149,7 @@ class ForYouScreenTest {
feed = emptyList(),
),
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -182,6 +186,7 @@ class ForYouScreenTest {
OnboardingUiState.Shown(topics = followableTopicTestData),
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -204,6 +209,7 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)
@ -227,6 +233,7 @@ class ForYouScreenTest {
feed = userNewsResourcesTestData,
),
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
)

@ -93,6 +93,7 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun ForYouRoute(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),
) {
@ -105,6 +106,7 @@ internal fun ForYouRoute(
onboardingUiState = onboardingUiState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier,
@ -117,6 +119,7 @@ internal fun ForYouScreen(
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
@ -176,6 +179,7 @@ internal fun ForYouScreen(
newsFeed(
feedState = feedState,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onTopicClick = onTopicClick,
)
item(span = { GridItemSpan(maxLineSpan) }) {
@ -409,6 +413,7 @@ fun ForYouScreenPopulatedFeed(
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -431,6 +436,7 @@ fun ForYouScreenOfflinePopulatedFeed(
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -455,6 +461,7 @@ fun ForYouScreenTopicSelection(
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -472,6 +479,7 @@ fun ForYouScreenLoading() {
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -494,6 +502,7 @@ fun ForYouScreenPopulatedAndLoading(
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onTopicClick = {},
)
}
}

@ -28,8 +28,8 @@ fun NavController.navigateToForYou(navOptions: NavOptions? = null) {
this.navigate(forYouNavigationRoute, navOptions)
}
fun NavGraphBuilder.forYouScreen() {
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(route = forYouNavigationRoute) {
ForYouRoute()
ForYouRoute(onTopicClick)
}
}

@ -109,7 +109,7 @@ class InterestsScreenTest {
InterestsScreen(
uiState = uiState,
followTopic = { _, _ -> },
navigateToTopic = {},
onTopicClick = {},
)
}
}

@ -38,7 +38,7 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun InterestsRoute(
navigateToTopic: (String) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel(),
) {
@ -47,7 +47,7 @@ internal fun InterestsRoute(
InterestsScreen(
uiState = uiState,
followTopic = viewModel::followTopic,
navigateToTopic = navigateToTopic,
onTopicClick = onTopicClick,
modifier = modifier,
)
}
@ -56,7 +56,7 @@ internal fun InterestsRoute(
internal fun InterestsScreen(
uiState: InterestsUiState,
followTopic: (String, Boolean) -> Unit,
navigateToTopic: (String) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -72,7 +72,7 @@ internal fun InterestsScreen(
is InterestsUiState.Interests ->
TopicsTabContent(
topics = uiState.topics,
onTopicClick = navigateToTopic,
onTopicClick = onTopicClick,
onFollowButtonClick = followTopic,
modifier = modifier,
)
@ -100,7 +100,7 @@ fun InterestsScreenPopulated(
topics = followableTopics,
),
followTopic = { _, _ -> },
navigateToTopic = {},
onTopicClick = {},
)
}
}
@ -114,7 +114,7 @@ fun InterestsScreenLoading() {
InterestsScreen(
uiState = InterestsUiState.Loading,
followTopic = { _, _ -> },
navigateToTopic = {},
onTopicClick = {},
)
}
}
@ -128,7 +128,7 @@ fun InterestsScreenEmpty() {
InterestsScreen(
uiState = InterestsUiState.Empty,
followTopic = { _, _ -> },
navigateToTopic = {},
onTopicClick = {},
)
}
}

@ -31,7 +31,7 @@ fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
}
fun NavGraphBuilder.interestsGraph(
navigateToTopic: (String) -> Unit,
onTopicClick: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit,
) {
navigation(
@ -39,9 +39,7 @@ fun NavGraphBuilder.interestsGraph(
startDestination = interestsRoute,
) {
composable(route = interestsRoute) {
InterestsRoute(
navigateToTopic = navigateToTopic,
)
InterestsRoute(onTopicClick)
}
nestedGraphs()
}

@ -55,8 +55,9 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
)
}
@ -73,8 +74,9 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
)
}
@ -96,8 +98,9 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(userNewsResourcesTestData),
onBackClick = { },
onFollowClick = { },
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
)
}
@ -117,8 +120,9 @@ class TopicScreenTest {
newsUiState = NewsUiState.Success(
userNewsResourcesTestData,
),
onBackClick = { },
onFollowClick = { },
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
onBookmarkChanged = { _, _ -> },
)
}

@ -66,6 +66,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
@ -80,6 +81,7 @@ internal fun TopicRoute(
onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews,
onTopicClick = onTopicClick,
)
}
@ -90,6 +92,7 @@ internal fun TopicScreen(
newsUiState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onTopicClick: (String) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
@ -126,6 +129,7 @@ internal fun TopicScreen(
news = newsUiState,
imageUrl = topicUiState.followableTopic.topic.imageUrl,
onBookmarkChanged = onBookmarkChanged,
onTopicClick = onTopicClick,
)
}
}
@ -141,13 +145,14 @@ private fun LazyListScope.TopicBody(
news: NewsUiState,
imageUrl: String,
onBookmarkChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
) {
// TODO: Show icon if available
item {
TopicHeader(name, description, imageUrl)
}
userNewsResourceCards(news, onBookmarkChanged)
userNewsResourceCards(news, onBookmarkChanged, onTopicClick)
}
@Composable
@ -178,12 +183,14 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
private fun LazyListScope.userNewsResourceCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
) {
when (news) {
is NewsUiState.Success -> {
userNewsResourceCardItems(
items = news.news,
onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },
onTopicClick = onTopicClick,
itemModifier = Modifier.padding(24.dp),
)
}
@ -204,11 +211,12 @@ private fun TopicBodyPreview() {
NiaTheme {
LazyColumn {
TopicBody(
"Jetpack Compose",
"Lorem ipsum maximum",
NewsUiState.Success(emptyList()),
"",
{ _, _ -> },
name = "Jetpack Compose",
description = "Lorem ipsum maximum",
news = NewsUiState.Success(emptyList()),
imageUrl = "",
onBookmarkChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -265,6 +273,7 @@ fun TopicScreenPopulated(
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
onTopicClick = {},
)
}
}
@ -281,6 +290,7 @@ fun TopicScreenLoading() {
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
onTopicClick = {},
)
}
}

@ -42,6 +42,7 @@ fun NavController.navigateToTopic(topicId: String) {
fun NavGraphBuilder.topicScreen(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
composable(
route = "topic_route/{$topicIdArg}",
@ -49,6 +50,6 @@ fun NavGraphBuilder.topicScreen(
navArgument(topicIdArg) { type = NavType.StringType },
),
) {
TopicRoute(onBackClick = onBackClick)
TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick)
}
}

@ -131,6 +131,7 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }

@ -85,7 +85,6 @@ class DesignSystemDetector : Detector(), Detector.UastScanner {
"TextButton" to "NiaTextButton",
"FilterChip" to "NiaFilterChip",
"ElevatedFilterChip" to "NiaFilterChip",
"DropdownMenu" to "NiaDropdownMenu",
"NavigationBar" to "NiaNavigationBar",
"NavigationBarItem" to "NiaNavigationBarItem",
"NavigationRail" to "NiaNavigationRail",

Loading…
Cancel
Save