Merge branch 'main' into tm/fix-benchmarks-no-people

Change-Id: I94ca19fad5527c11a2f0f28cc43a23ab22f419a7
pull/545/head
mlykotom 2 years ago
commit 6b48b355cc

@ -0,0 +1,35 @@
name: Android CI with GMD
on:
push:
branches:
- main
pull_request:
jobs:
android-ci:
runs-on: macos-12
steps:
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- uses: actions/checkout@v3
- name: Setup Android SDK
uses: android-actions/setup-android@v2
- name: Run instrumented tests with GMD
run: ./gradlew cleanManagedDevices --unused-only &&
./gradlew pixel4api30DemoDebugAndroidTest -Dorg.gradle.workers.max=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info
- name: Upload test reports
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: |
'**/*/build/reports/androidTests/'

@ -16,22 +16,27 @@
-->
<component name="ProjectRunConfigurationManager">
<!--
Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding interpretation and just-in-time (JIT) compilation steps for included code paths.
Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding
interpretation and just-in-time (JIT) compilation steps for included code paths.
More information at http://d.android.com/baseline-profiles.
In this run configuration we leverage rerun parameter that always reruns the requested task regardless of cache.
We also leverage enable-display parameter to be able to verify the generator works as intended.
-->
<configuration default="false" name="Generate Demo Baseline Profile" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<!-- TODO Once we use Gradle wrapper 7.6, we can use rerun instead of rerun-tasks that will skip cache only for one task -->
<option name="scriptParameters" value="--rerun-tasks -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="scriptParameters" value="-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" />
<option value="--rerun" />
<option value="--enable-display" />
</list>
</option>
<option name="vmOptions" />

@ -30,9 +30,15 @@ in.
# Development Environment
**Now in Android** uses the Gradle build system and can be imported directly into the latest stable
version of Android Studio (available [here](https://developer.android.com/studio)). The `debug`
build can be built and run using the default configuration.
**Now in Android** uses the Gradle build system and can be imported directly into Android Studio (make sure you are using the latest stable version available [here](https://developer.android.com/studio)).
Change the run configuration to `app`.
![image](https://user-images.githubusercontent.com/873212/210559920-ef4a40c5-c8e0-478b-bb00-4879a8cf184a.png)
The `demoDebug` and `demoRelease` build variants can be built and run (the `prod` variants use a backend server which is not currently publicly available).
![image](https://user-images.githubusercontent.com/873212/210560507-44045dc5-b6d5-41ca-9746-f0f7acf22f8e.png)
Once you're up and running, you can refer to the learning journeys below to get a better
understanding of which libraries and tools are being used, the reasoning behind the approaches to

@ -37,16 +37,16 @@ import androidx.compose.ui.Modifier
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.NiaFilledButton
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
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOutlinedButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaViewToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -77,7 +77,7 @@ fun NiaCatalog() {
item { Text("Buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(onClick = {}) {
NiaButton(onClick = {}) {
Text(text = "Enabled")
}
NiaOutlinedButton(onClick = {}) {
@ -91,7 +91,7 @@ fun NiaCatalog() {
item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
NiaButton(
onClick = {},
enabled = false
) {
@ -114,7 +114,7 @@ fun NiaCatalog() {
item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
NiaButton(
onClick = {},
text = { Text(text = "Enabled") },
leadingIcon = {
@ -140,7 +140,7 @@ fun NiaCatalog() {
item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
NiaButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
@ -166,251 +166,24 @@ fun NiaCatalog() {
)
}
}
item { Text("Buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Disabled buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Small buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
NiaOutlinedButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
NiaTextButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
}
}
item { Text("Disabled small buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
small = true
) {
Text(text = "Disabled")
}
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true
) {
Text(text = "Disabled")
}
NiaTextButton(
onClick = {},
enabled = false,
small = true
) {
Text(text = "Disabled")
}
}
}
item { Text("Small buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item {
Text(
"Disabled small buttons with leading icons",
Modifier.padding(top = 16.dp)
)
}
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Small buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item {
Text(
"Disabled small buttons with trailing icons",
Modifier.padding(top = 16.dp)
)
}
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
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("Dropdown menu", Modifier.padding(top = 16.dp)) }
item {
NiaDropdownMenuButton(
text = { Text("Newest first") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) }
)
}
item { Text("Chips", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
@ -418,28 +191,33 @@ fun NiaCatalog() {
NiaFilterChip(
selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled".uppercase()) }
label = { Text(text = "Enabled") }
)
var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip(
selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled".uppercase()) }
label = { Text(text = "Enabled") }
)
var thirdChecked by remember { mutableStateOf(true) }
NiaFilterChip(
selected = thirdChecked,
onSelectedChange = { checked -> thirdChecked = checked },
selected = false,
onSelectedChange = {},
enabled = false,
label = { Text(text = "Disabled".uppercase()) }
label = { Text(text = "Disabled") }
)
NiaFilterChip(
selected = true,
onSelectedChange = {},
enabled = false,
label = { Text(text = "Disabled") }
)
}
}
item { Text("Toggle buttons", Modifier.padding(top = 16.dp)) }
item { Text("Icon buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
var firstChecked by remember { mutableStateOf(false) }
NiaToggleButton(
NiaIconToggleButton(
checked = firstChecked,
onCheckedChange = { checked -> firstChecked = checked },
icon = {
@ -456,7 +234,7 @@ fun NiaCatalog() {
}
)
var secondChecked by remember { mutableStateOf(true) }
NiaToggleButton(
NiaIconToggleButton(
checked = secondChecked,
onCheckedChange = { checked -> secondChecked = checked },
icon = {
@ -472,27 +250,39 @@ fun NiaCatalog() {
)
}
)
var thirdChecked by remember { mutableStateOf(false) }
NiaToggleButton(
checked = thirdChecked,
onCheckedChange = { checked -> thirdChecked = checked },
NiaIconToggleButton(
checked = false,
onCheckedChange = {},
icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
)
},
checkedIcon = {
Icon(imageVector = NiaIcons.Check, contentDescription = null)
}
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
)
},
enabled = false
)
var fourthChecked by remember { mutableStateOf(true) }
NiaToggleButton(
checked = fourthChecked,
onCheckedChange = { checked -> fourthChecked = checked },
NiaIconToggleButton(
checked = true,
onCheckedChange = {},
icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
)
},
checkedIcon = {
Icon(imageVector = NiaIcons.Check, contentDescription = null)
}
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
)
},
enabled = false
)
}
}
@ -513,6 +303,13 @@ fun NiaCatalog() {
compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }
)
NiaViewToggleButton(
expanded = false,
onExpandedChange = {},
compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") },
enabled = false
)
}
}
item { Text("Tags", Modifier.padding(top = 16.dp)) }
@ -524,7 +321,7 @@ fun NiaCatalog() {
NiaTopicTag(
expanded = expandedTopicId == "Topic 1",
followed = firstFollowed,
onDropMenuToggle = { show ->
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
@ -539,7 +336,7 @@ fun NiaCatalog() {
NiaTopicTag(
expanded = expandedTopicId == "Topic 2",
followed = secondFollowed,
onDropMenuToggle = { show ->
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
@ -550,6 +347,16 @@ fun NiaCatalog() {
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
)
NiaTopicTag(
expanded = false,
followed = false,
onDropdownMenuToggle = {},
onFollowClick = {},
onUnfollowClick = {},
onBrowseClick = {},
text = { Text(text = "Disabled".uppercase()) },
enabled = false
)
}
}
item { Text("Tabs", Modifier.padding(top = 16.dp)) }

@ -14,6 +14,7 @@
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.application")
@ -73,6 +74,17 @@ android {
unitTests {
isIncludeAndroidResources = true
}
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
namespace = "com.google.samples.apps.nowinandroid"
}

@ -128,7 +128,7 @@ class NavigationUiTest {
}
@Test
fun compcatWidth_mediumHeight_showsNavigationBar() {
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {

@ -39,7 +39,7 @@ class NiaApplication : Application(), ImageLoaderFactory {
* format. During Coil's initialization it will call `applicationContext.newImageLoader()` to
* obtain an ImageLoader.
*
* @see https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt#L63
* @see <a href="https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt">Coil</a>
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
@ -49,7 +50,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
@ -66,6 +66,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
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.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
@ -86,68 +88,26 @@ fun NiaApp(
windowSizeClass = windowSizeClass
),
) {
val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentTopLevelDestination) {
TopLevelDestination.FOR_YOU -> {
content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
background {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
// When the nav rail is displayed, the top app bar will, by default
// overlap it. This means that the top most item in the nav rail
// won't be tappable. A workaround is to position the top app bar
// behind the nav rail using zIndex.
modifier = Modifier.zIndex(-1F),
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
onActionClick = { appState.setShowSettingsDialog(true) }
)
}
NiaBackground {
NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current
} else {
GradientColors()
},
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar")
)
}
}
) { padding ->
) {
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// If user is not connected to the internet show a snack bar to inform them.
val notConnected = stringResource(R.string.not_connected)
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnected,
message = notConnectedMessage,
duration = Indefinite
)
}
@ -158,37 +118,73 @@ fun NiaApp(
)
}
Row(
Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar")
)
)
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding()
)
}
}
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick,
modifier = Modifier
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumedWindowInsets(padding)
)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding()
)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
Column(Modifier.fillMaxSize()) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
onActionClick = { appState.setShowSettingsDialog(true) }
)
}
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick
)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
}
}
}

@ -67,6 +67,7 @@ android {
experimentalProperties["android.experimental.self-instrumenting"] = true
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {

@ -21,8 +21,8 @@ plugins {
group = "com.google.samples.apps.nowinandroid.buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
dependencies {

@ -27,8 +27,10 @@ class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.kapt")
apply("dagger.hilt.android.plugin")
// KAPT must go last to avoid build warnings.
// See: https://stackoverflow.com/questions/70550883/warning-the-following-options-were-not-recognized-by-any-processor-dagger-f
apply("org.jetbrains.kotlin.kapt")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
@ -31,9 +34,24 @@ android {
arg("room.schemaLocation", "$projectDir/schemas")
}
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.database"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}
dependencies {

@ -21,18 +21,21 @@ import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.BackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidBackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkAndroidGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.DarkDefaultColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidBackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroidGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightDefaultColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightDefaultGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
@ -42,7 +45,7 @@ import org.junit.Test
/**
* Tests [NiaTheme] using different combinations of the theme mode parameters:
* darkTheme, dynamicColor, and androidTheme.
* darkTheme, disableDynamicTheming, and androidTheme.
*
* It verifies that the various composition locals [MaterialTheme], [LocalGradientColors] and
* [LocalBackgroundTheme] have the expected values for a given theme mode, as specified by the
@ -63,12 +66,9 @@ class ThemeTest {
) {
val colorScheme = LightDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = LightDefaultGradientColors
val gradientColors = defaultGradientColors(colorScheme)
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
@ -84,12 +84,9 @@ class ThemeTest {
) {
val colorScheme = DarkDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = defaultGradientColors(colorScheme)
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
@ -102,22 +99,11 @@ class ThemeTest {
darkTheme = false,
androidTheme = false
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(LocalContext.current)
} else {
LightDefaultColorScheme
}
val colorScheme = dynamicLightColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GradientColors()
} else {
LightDefaultGradientColors
}
val gradientColors = dynamicGradientColorsWithFallback(colorScheme)
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
@ -130,18 +116,11 @@ class ThemeTest {
darkTheme = true,
androidTheme = false
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
DarkDefaultColorScheme
}
val colorScheme = dynamicDarkColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = dynamicGradientColorsWithFallback(colorScheme)
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
@ -157,7 +136,7 @@ class ThemeTest {
) {
val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = LightAndroidGradientColors
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
@ -175,7 +154,7 @@ class ThemeTest {
) {
val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = DarkAndroidGradientColors
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
@ -192,7 +171,7 @@ class ThemeTest {
) {
val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = LightAndroidGradientColors
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
@ -209,7 +188,7 @@ class ThemeTest {
) {
val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
val gradientColors = DarkAndroidGradientColors
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
@ -217,6 +196,51 @@ class ThemeTest {
}
}
@Composable
private fun dynamicLightColorSchemeWithFallback(): ColorScheme {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(LocalContext.current)
} else {
LightDefaultColorScheme
}
}
@Composable
private fun dynamicDarkColorSchemeWithFallback(): ColorScheme {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
DarkDefaultColorScheme
}
}
private fun emptyGradientColors(colorScheme: ColorScheme): GradientColors {
return GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))
}
private fun defaultGradientColors(colorScheme: ColorScheme): GradientColors {
return GradientColors(
top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer,
container = colorScheme.surface
)
}
private fun dynamicGradientColorsWithFallback(colorScheme: ColorScheme): GradientColors {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
emptyGradientColors(colorScheme)
} else {
defaultGradientColors(colorScheme)
}
}
private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme {
return BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
}
/**
* Workaround for the fact that the NiA design system specify all color scheme values.
*/
@ -249,6 +273,8 @@ class ThemeTest {
assertEquals(expectedColorScheme.onSurface, actualColorScheme.onSurface)
assertEquals(expectedColorScheme.surfaceVariant, actualColorScheme.surfaceVariant)
assertEquals(expectedColorScheme.onSurfaceVariant, actualColorScheme.onSurfaceVariant)
assertEquals(expectedColorScheme.inverseSurface, actualColorScheme.inverseSurface)
assertEquals(expectedColorScheme.inverseOnSurface, actualColorScheme.inverseOnSurface)
assertEquals(expectedColorScheme.outline, actualColorScheme.outline)
}
}

@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
@ -41,7 +42,7 @@ import kotlin.math.tan
/**
* The main background for the app.
* Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Box].
* Uses [LocalBackgroundTheme] to set the color and tonal elevation of a [Surface].
*
* @param modifier Modifier to be applied to the background.
* @param content The background content.
@ -66,23 +67,28 @@ fun NiaBackground(
/**
* A gradient background for select screens. Uses [LocalBackgroundTheme] to set the gradient colors
* of a [Box].
* of a [Box] within a [Surface].
*
* @param modifier Modifier to be applied to the background.
* @param topColor The top gradient color to be rendered.
* @param bottomColor The bottom gradient color to be rendered.
* @param gradientColors The gradient colors to be rendered.
* @param content The background content.
*/
@Composable
fun NiaGradientBackground(
modifier: Modifier = Modifier,
topColor: Color = LocalGradientColors.current.primary,
bottomColor: Color = LocalGradientColors.current.secondary,
gradientColors: GradientColors = LocalGradientColors.current,
content: @Composable () -> Unit
) {
val currentTopColor by rememberUpdatedState(topColor)
val currentBottomColor by rememberUpdatedState(bottomColor)
NiaBackground(modifier) {
val currentTopColor by rememberUpdatedState(gradientColors.top)
val currentBottomColor by rememberUpdatedState(gradientColors.bottom)
Surface(
color = if (gradientColors.container == Color.Unspecified) {
Color.Transparent
} else {
gradientColors.container
},
modifier = modifier.fillMaxSize()
) {
Box(
Modifier
.fillMaxSize()

@ -20,20 +20,15 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
@ -43,38 +38,27 @@ import androidx.compose.ui.unit.dp
* @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 small Whether or not the size of the button should be small or regular.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.filledButtonColors].
* @param contentPadding The spacing values to apply internally between the container and the
* content. See [NiaButtonDefaults.buttonContentPadding].
* content.
* @param content The button content.
*/
@Composable
fun NiaFilledButton(
fun NiaButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
colors: ButtonColors = NiaButtonDefaults.filledButtonColors(),
contentPadding: PaddingValues = NiaButtonDefaults.buttonContentPadding(small = small),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
Button(
onClick = onClick,
modifier = if (small) {
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},
modifier = modifier,
enabled = enabled,
colors = colors,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.onBackground
),
contentPadding = contentPadding,
content = {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
content()
}
}
content = content
)
}
@ -85,40 +69,30 @@ fun NiaFilledButton(
* @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 small Whether or not the size of the button should be small or regular.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.filledButtonColors].
* @param text The button text label content.
* @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.
* @param trailingIcon The button trailing icon content. Pass `null` here for no trailing icon.
*/
@Composable
fun NiaFilledButton(
fun NiaButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
colors: ButtonColors = NiaButtonDefaults.filledButtonColors(),
text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null
leadingIcon: @Composable (() -> Unit)? = null
) {
NiaFilledButton(
NiaButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
small = small,
colors = colors,
contentPadding = NiaButtonDefaults.buttonContentPadding(
small = small,
leadingIcon = leadingIcon != null,
trailingIcon = trailingIcon != null
)
contentPadding = if (leadingIcon != null) {
ButtonDefaults.ButtonWithIconContentPadding
} else {
ButtonDefaults.ContentPadding
}
) {
NiaButtonContent(
text = text,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
leadingIcon = leadingIcon
)
}
}
@ -130,12 +104,8 @@ fun NiaFilledButton(
* @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 small Whether or not the size of the button should be small or regular.
* @param border Border to draw around the button. Pass `null` here for no border.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.outlinedButtonColors].
* @param contentPadding The spacing values to apply internally between the container and the
* content. See [NiaButtonDefaults.buttonContentPadding].
* content.
* @param content The button content.
*/
@Composable
@ -143,28 +113,28 @@ fun NiaOutlinedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
border: BorderStroke? = NiaButtonDefaults.outlinedButtonBorder(enabled = enabled),
colors: ButtonColors = NiaButtonDefaults.outlinedButtonColors(),
contentPadding: PaddingValues = NiaButtonDefaults.buttonContentPadding(small = small),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
OutlinedButton(
onClick = onClick,
modifier = if (small) {
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},
modifier = modifier,
enabled = enabled,
border = border,
colors = colors,
contentPadding = contentPadding,
content = {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
content()
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground
),
border = BorderStroke(
width = NiaButtonDefaults.OutlinedButtonBorderWidth,
color = if (enabled) {
MaterialTheme.colorScheme.outline
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha
)
}
}
),
contentPadding = contentPadding,
content = content
)
}
@ -175,43 +145,30 @@ fun NiaOutlinedButton(
* @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 small Whether or not the size of the button should be small or regular.
* @param border Border to draw around the button. Pass `null` here for no border.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.outlinedButtonColors].
* @param text The button text label content.
* @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.
* @param trailingIcon The button trailing icon content. Pass `null` here for no trailing icon.
*/
@Composable
fun NiaOutlinedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
border: BorderStroke? = NiaButtonDefaults.outlinedButtonBorder(enabled = enabled),
colors: ButtonColors = NiaButtonDefaults.outlinedButtonColors(),
text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null
leadingIcon: @Composable (() -> Unit)? = null
) {
NiaOutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
small = small,
border = border,
colors = colors,
contentPadding = NiaButtonDefaults.buttonContentPadding(
small = small,
leadingIcon = leadingIcon != null,
trailingIcon = trailingIcon != null
)
contentPadding = if (leadingIcon != null) {
ButtonDefaults.ButtonWithIconContentPadding
} else {
ButtonDefaults.ContentPadding
}
) {
NiaButtonContent(
text = text,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
leadingIcon = leadingIcon
)
}
}
@ -223,11 +180,6 @@ fun NiaOutlinedButton(
* @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 small Whether or not the size of the button should be small or regular.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.textButtonColors].
* @param contentPadding The spacing values to apply internally between the container and the
* content. See [NiaButtonDefaults.buttonContentPadding].
* @param content The button content.
*/
@Composable
@ -235,26 +187,16 @@ fun NiaTextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
colors: ButtonColors = NiaButtonDefaults.textButtonColors(),
contentPadding: PaddingValues = NiaButtonDefaults.buttonContentPadding(small = small),
content: @Composable RowScope.() -> Unit
) {
TextButton(
onClick = onClick,
modifier = if (small) {
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},
modifier = modifier,
enabled = enabled,
colors = colors,
contentPadding = contentPadding,
content = {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
content()
}
}
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground
),
content = content
)
}
@ -265,60 +207,42 @@ fun NiaTextButton(
* @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 small Whether or not the size of the button should be small or regular.
* @param colors [ButtonColors] that will be used to resolve the container and content color for
* this button in different states. See [NiaButtonDefaults.textButtonColors].
* @param text The button text label content.
* @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.
* @param trailingIcon The button trailing icon content. Pass `null` here for no trailing icon.
*/
@Composable
fun NiaTextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
small: Boolean = false,
colors: ButtonColors = NiaButtonDefaults.textButtonColors(),
text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null
leadingIcon: @Composable (() -> Unit)? = null
) {
NiaTextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
small = small,
colors = colors,
contentPadding = NiaButtonDefaults.buttonContentPadding(
small = small,
leadingIcon = leadingIcon != null,
trailingIcon = trailingIcon != null
)
enabled = enabled
) {
NiaButtonContent(
text = text,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
leadingIcon = leadingIcon
)
}
}
/**
* Internal Now in Android button content layout for arranging the text label, leading icon and
* trailing icon.
* Internal Now in Android button content layout for arranging the text label and leading icon.
*
* @param text The button text label content.
* @param leadingIcon The button leading icon content. Pass `null` here for no leading icon.
* @param trailingIcon The button trailing icon content. Pass `null` here for no trailing icon.
* @param leadingIcon The button leading icon content. Default is `null` for no leading icon.Ï
*/
@Composable
private fun RowScope.NiaButtonContent(
private fun NiaButtonContent(
text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)?,
trailingIcon: @Composable (() -> Unit)?
leadingIcon: @Composable (() -> Unit)? = null
) {
if (leadingIcon != null) {
Box(Modifier.sizeIn(maxHeight = NiaButtonDefaults.ButtonIconSize)) {
Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) {
leadingIcon()
}
}
@ -326,12 +250,7 @@ private fun RowScope.NiaButtonContent(
Modifier
.padding(
start = if (leadingIcon != null) {
NiaButtonDefaults.ButtonContentSpacing
} else {
0.dp
},
end = if (trailingIcon != null) {
NiaButtonDefaults.ButtonContentSpacing
ButtonDefaults.IconSpacing
} else {
0.dp
}
@ -339,104 +258,16 @@ private fun RowScope.NiaButtonContent(
) {
text()
}
if (trailingIcon != null) {
Box(Modifier.sizeIn(maxHeight = NiaButtonDefaults.ButtonIconSize)) {
trailingIcon()
}
}
}
/**
* Now in Android button default values.
*/
object NiaButtonDefaults {
val SmallButtonHeight = 32.dp
const val DisabledButtonContainerAlpha = 0.12f
const val DisabledButtonContentAlpha = 0.38f
val ButtonHorizontalPadding = 24.dp
val ButtonHorizontalIconPadding = 16.dp
val ButtonVerticalPadding = 8.dp
val SmallButtonHorizontalPadding = 16.dp
val SmallButtonHorizontalIconPadding = 12.dp
val SmallButtonVerticalPadding = 7.dp
val ButtonContentSpacing = 8.dp
val ButtonIconSize = 18.dp
fun buttonContentPadding(
small: Boolean,
leadingIcon: Boolean = false,
trailingIcon: Boolean = false
): PaddingValues {
return PaddingValues(
start = when {
small && leadingIcon -> SmallButtonHorizontalIconPadding
small -> SmallButtonHorizontalPadding
leadingIcon -> ButtonHorizontalIconPadding
else -> ButtonHorizontalPadding
},
top = if (small) SmallButtonVerticalPadding else ButtonVerticalPadding,
end = when {
small && trailingIcon -> SmallButtonHorizontalIconPadding
small -> SmallButtonHorizontalPadding
trailingIcon -> ButtonHorizontalIconPadding
else -> ButtonHorizontalPadding
},
bottom = if (small) SmallButtonVerticalPadding else ButtonVerticalPadding
)
}
@Composable
fun filledButtonColors(
containerColor: Color = MaterialTheme.colorScheme.onBackground,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor: Color = MaterialTheme.colorScheme.onBackground.copy(
alpha = DisabledButtonContainerAlpha
),
disabledContentColor: Color = MaterialTheme.colorScheme.onBackground.copy(
alpha = DisabledButtonContentAlpha
)
) = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor
)
@Composable
fun outlinedButtonBorder(
enabled: Boolean,
width: Dp = 1.dp,
color: Color = MaterialTheme.colorScheme.onBackground,
disabledColor: Color = MaterialTheme.colorScheme.onBackground.copy(
alpha = DisabledButtonContainerAlpha
)
): BorderStroke = BorderStroke(
width = width,
color = if (enabled) color else disabledColor
)
@Composable
fun outlinedButtonColors(
containerColor: Color = Color.Transparent,
contentColor: Color = MaterialTheme.colorScheme.onBackground,
disabledContainerColor: Color = Color.Transparent,
disabledContentColor: Color = MaterialTheme.colorScheme.onBackground.copy(
alpha = DisabledButtonContentAlpha
)
) = ButtonDefaults.outlinedButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor
)
@Composable
fun textButtonColors(
containerColor: Color = Color.Transparent,
contentColor: Color = MaterialTheme.colorScheme.onBackground,
disabledContainerColor: Color = Color.Transparent,
disabledContentColor: Color = MaterialTheme.colorScheme.onBackground.copy(
alpha = DisabledButtonContentAlpha
)
) = ButtonDefaults.textButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor = disabledContentColor
)
// TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default
const val DisabledOutlinedButtonBorderAlpha = 0.12f
// TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults
val OutlinedButtonBorderWidth = 1.dp
}

@ -58,11 +58,15 @@ fun NiaFilterChip(
},
modifier = modifier,
enabled = enabled,
trailingIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
leadingIcon = if (selected) {
{
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
}
} else {
null
},
shape = CircleShape,
border = FilterChipDefaults.filterChipBorder(
@ -74,11 +78,9 @@ fun NiaFilterChip(
disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha
),
borderWidth = NiaChipDefaults.ChipBorderWidth,
selectedBorderWidth = NiaChipDefaults.ChipBorderWidth
),
colors = FilterChipDefaults.filterChipColors(
containerColor = Color.Transparent,
labelColor = MaterialTheme.colorScheme.onBackground,
iconColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = if (selected) {
@ -105,6 +107,8 @@ fun NiaFilterChip(
* Now in Android chip default values.
*/
object NiaChipDefaults {
// TODO: File bug
// FilterChip default values aren't exposed via FilterChipDefaults
const val DisabledChipContainerAlpha = 0.12f
const val DisabledChipContentAlpha = 0.38f
val ChipBorderWidth = 1.dp

@ -16,16 +16,25 @@
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
/**
@ -56,17 +65,38 @@ fun <T> NiaDropdownMenuButton(
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
NiaOutlinedButton(
OutlinedButton(
onClick = { expanded = true },
enabled = enabled,
text = text,
trailingIcon = {
Icon(
imageVector = if (expanded) NiaIcons.ArrowDropUp else NiaIcons.ArrowDropDown,
contentDescription = null
)
}
)
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 },
@ -80,6 +110,39 @@ fun <T> NiaDropdownMenuButton(
}
}
/**
* 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].
@ -130,3 +193,24 @@ fun <T> NiaDropdownMenu(
}
}
}
/**
* 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
)
}

@ -0,0 +1,78 @@
/*
* 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.material3.FilledIconToggleButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
/**
* Now in Android toggle button with icon and checked icon content slots. Wraps Material 3
* [IconButton].
*
* @param checked Whether the toggle button is currently checked.
* @param onCheckedChange Called when the user clicks the toggle button and toggles checked.
* @param modifier Modifier to be applied to the toggle button.
* @param enabled Controls the enabled state of the toggle button. When `false`, this toggle button
* will not be clickable and will appear disabled to accessibility services.
* @param icon The icon content to show when unchecked.
* @param checkedIcon The icon content to show when checked.
*/
@Composable
fun NiaIconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable () -> Unit,
checkedIcon: @Composable () -> Unit = icon
) {
// TODO: File bug
// Can't use regular IconToggleButton as it doesn't include a shape (appears square)
FilledIconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier,
enabled = enabled,
colors = IconButtonDefaults.iconToggleButtonColors(
checkedContainerColor = MaterialTheme.colorScheme.primaryContainer,
checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha
)
} else {
Color.Transparent
}
)
) {
if (checked) checkedIcon() else icon()
}
}
/**
* Now in Android icon button default values.
*/
object NiaIconButtonDefaults {
// TODO: File bug
// IconToggleButton disabled container alpha not exposed by IconButtonDefaults
const val DisabledIconButtonContainerAlpha = 0.12f
}

@ -17,24 +17,26 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component
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.graphics.Color
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,
onDropMenuToggle: (show: Boolean) -> Unit = {},
onDropdownMenuToggle: (show: Boolean) -> Unit = {},
onFollowClick: () -> Unit,
onUnfollowClick: () -> Unit,
onBrowseClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: @Composable () -> Unit,
followText: @Composable () -> Unit = { Text(stringResource(R.string.follow)) },
@ -46,28 +48,28 @@ fun NiaTopicTag(
val containerColor = if (followed) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha
)
}
NiaTextButton(
onClick = { onDropMenuToggle(true) },
TextButton(
onClick = { onDropdownMenuToggle(true) },
enabled = enabled,
small = true,
colors = NiaButtonDefaults.textButtonColors(
colors = ButtonDefaults.textButtonColors(
containerColor = containerColor,
contentColor = contentColorFor(backgroundColor = containerColor),
disabledContainerColor = if (followed) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaButtonDefaults.DisabledButtonContentAlpha
)
} else {
Color.Transparent
}
),
text = text
)
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaTagDefaults.DisabledTopicTagContainerAlpha
)
)
) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text()
}
}
NiaDropdownMenu(
expanded = expanded,
onDismissRequest = { onDropMenuToggle(false) },
onDismissRequest = { onDropdownMenuToggle(false) },
items = if (followed) listOf(UNFOLLOW, BROWSE) else listOf(FOLLOW, BROWSE),
onItemClick = { item ->
when (item) {
@ -87,6 +89,16 @@ fun NiaTopicTag(
}
}
/**
* Now in Android tag default values.
*/
object NiaTagDefaults {
const val UnfollowedTopicTagContainerAlpha = 0.5f
// TODO: File bug
// 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,107 +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.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Now in Android toggle button with icon and checked icon content slots. Wraps Material 3
* [IconButton].
*
* @param checked Whether the toggle button is currently checked.
* @param onCheckedChange Called when the user clicks the toggle button and toggles checked.
* @param modifier Modifier to be applied to the toggle button.
* @param enabled Controls the enabled state of the toggle button. When `false`, this toggle button
* will not be clickable and will appear disabled to accessibility services.
* @param icon The icon content to show when unchecked.
* @param checkedIcon The icon content to show when checked.
* @param size The size of the toggle button.
* @param iconSize The size of the icon.
* @param backgroundColor The background color when unchecked.
* @param checkedBackgroundColor The background color when checked.
* @param iconColor The icon color when unchecked.
* @param iconColor The icon color when checked.
*/
@Composable
fun NiaToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable () -> Unit,
checkedIcon: @Composable () -> Unit = icon,
size: Dp = NiaToggleButtonDefaults.ToggleButtonSize,
iconSize: Dp = NiaToggleButtonDefaults.ToggleButtonIconSize,
backgroundColor: Color = Color.Transparent,
checkedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
iconColor: Color = contentColorFor(backgroundColor),
checkedIconColor: Color = contentColorFor(checkedBackgroundColor)
) {
val radius = with(LocalDensity.current) { (size / 2).toPx() }
IconButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier
.size(size)
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {
onCheckedChange(!checked)
})
.drawBehind {
drawCircle(
color = if (checked) checkedBackgroundColor else backgroundColor,
radius = radius
)
},
enabled = enabled,
content = {
Box(
modifier = Modifier.sizeIn(
maxWidth = iconSize,
maxHeight = iconSize
)
) {
val contentColor = if (checked) checkedIconColor else iconColor
CompositionLocalProvider(LocalContentColor provides contentColor) {
if (checked) checkedIcon() else icon()
}
}
}
)
}
/**
* Now in Android toggle button default values.
*/
object NiaToggleButtonDefaults {
val ToggleButtonSize = 40.dp
val ToggleButtonIconSize = 18.dp
}

@ -14,6 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.annotation.StringRes
@ -103,7 +105,7 @@ fun NiaTopAppBar(
@OptIn(ExperimentalMaterial3Api::class)
@Preview("Top App Bar")
@Composable
fun NiaTopAppBarPreview() {
private fun NiaTopAppBarPreview() {
NiaTopAppBar(
titleRes = android.R.string.untitled,
navigationIcon = NiaIcons.Search,

@ -16,9 +16,18 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
/**
@ -42,16 +51,71 @@ fun NiaViewToggleButton(
compactText: @Composable () -> Unit,
expandedText: @Composable () -> Unit
) {
NiaTextButton(
TextButton(
onClick = { onExpandedChange(!expanded) },
modifier = modifier,
enabled = enabled,
text = if (expanded) expandedText else compactText,
trailingIcon = {
Icon(
imageVector = if (expanded) NiaIcons.ViewDay else NiaIcons.ShortText,
contentDescription = null
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground
),
contentPadding = NiaViewToggleDefaults.ViewToggleButtonContentPadding
) {
NiaViewToggleButtonContent(
text = if (expanded) expandedText else compactText,
trailingIcon = {
Icon(
imageVector = if (expanded) NiaIcons.ViewDay else NiaIcons.ShortText,
contentDescription = null
)
}
)
}
}
/**
* Internal Now in Android view toggle 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 NiaViewToggleButtonContent(
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 view toggle default values.
*/
object NiaViewToggleDefaults {
// TODO: File bug
// Various default button padding values aren't exposed via ButtonDefaults
val ViewToggleButtonContentPadding =
PaddingValues(
start = 16.dp,
top = 8.dp,
end = 12.dp,
bottom = 8.dp
)
}

@ -18,12 +18,12 @@ package com.google.samples.apps.nowinandroid.core.designsystem.icon
import androidx.annotation.DrawableRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.icons.rounded.ArrowDropUp
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ExpandLess
@ -48,8 +48,8 @@ object NiaIcons {
val AccountCircle = Icons.Outlined.AccountCircle
val Add = Icons.Rounded.Add
val ArrowBack = Icons.Rounded.ArrowBack
val ArrowDropDown = Icons.Rounded.ArrowDropDown
val ArrowDropUp = Icons.Rounded.ArrowDropUp
val ArrowDropDown = Icons.Default.ArrowDropDown
val ArrowDropUp = Icons.Default.ArrowDropUp
val Bookmark = R.drawable.ic_bookmark
val BookmarkBorder = R.drawable.ic_bookmark_border
val Bookmarks = R.drawable.ic_bookmarks

@ -21,13 +21,13 @@ import androidx.compose.ui.graphics.Color
/**
* Now in Android colors.
*/
internal val Blue10 = Color(0xFF001F29)
internal val Blue10 = Color(0xFF001F28)
internal val Blue20 = Color(0xFF003544)
internal val Blue30 = Color(0xFF004D61)
internal val Blue40 = Color(0xFF006781)
internal val Blue80 = Color(0xFF5DD4FB)
internal val Blue90 = Color(0xFFB5EAFF)
internal val Blue95 = Color(0xFFDCF5FF)
internal val Blue40 = Color(0xFF006780)
internal val Blue80 = Color(0xFF5DD5FC)
internal val Blue90 = Color(0xFFB8EAFF)
internal val Blue95 = Color(0xFFDDF4FF)
internal val DarkGreen10 = Color(0xFF0D1F12)
internal val DarkGreen20 = Color(0xFF223526)
internal val DarkGreen30 = Color(0xFF394B3C)
@ -35,10 +35,12 @@ internal val DarkGreen40 = Color(0xFF4F6352)
internal val DarkGreen80 = Color(0xFFB7CCB8)
internal val DarkGreen90 = Color(0xFFD3E8D3)
internal val DarkGreenGray10 = Color(0xFF1A1C1A)
internal val DarkGreenGray20 = Color(0xFF2F312E)
internal val DarkGreenGray90 = Color(0xFFE2E3DE)
internal val DarkGreenGray95 = Color(0xFFF0F1EC)
internal val DarkGreenGray99 = Color(0xFFFBFDF7)
internal val DarkPurpleGray10 = Color(0xFF201A1B)
internal val DarkPurpleGray20 = Color(0xFF362F30)
internal val DarkPurpleGray90 = Color(0xFFECDFE0)
internal val DarkPurpleGray95 = Color(0xFFFAEEEF)
internal val DarkPurpleGray99 = Color(0xFFFCFCFC)
@ -53,31 +55,31 @@ internal val GreenGray50 = Color(0xFF727971)
internal val GreenGray60 = Color(0xFF8B938A)
internal val GreenGray80 = Color(0xFFC1C9BF)
internal val GreenGray90 = Color(0xFFDDE5DB)
internal val Orange10 = Color(0xFF390C00)
internal val Orange20 = Color(0xFF5D1900)
internal val Orange10 = Color(0xFF380D00)
internal val Orange20 = Color(0xFF5B1A00)
internal val Orange30 = Color(0xFF812800)
internal val Orange40 = Color(0xFFA23F16)
internal val Orange80 = Color(0xFFFFB599)
internal val Orange90 = Color(0xFFFFDBCE)
internal val Orange95 = Color(0xFFFFEDE6)
internal val Purple10 = Color(0xFF36003D)
internal val Purple20 = Color(0xFF560A5E)
internal val Orange80 = Color(0xFFFFB59B)
internal val Orange90 = Color(0xFFFFDBCF)
internal val Orange95 = Color(0xFFFFEDE8)
internal val Purple10 = Color(0xFF36003C)
internal val Purple20 = Color(0xFF560A5D)
internal val Purple30 = Color(0xFF702776)
internal val Purple40 = Color(0xFF8C4190)
internal val Purple80 = Color(0xFFFFA8FF)
internal val Purple90 = Color(0xFFFFD5FC)
internal val Purple95 = Color(0xFFFFEBFB)
internal val PurpleGray30 = Color(0xFF4E444C)
internal val Purple40 = Color(0xFF8B418F)
internal val Purple80 = Color(0xFFFFA9FE)
internal val Purple90 = Color(0xFFFFD6FA)
internal val Purple95 = Color(0xFFFFEBFA)
internal val PurpleGray30 = Color(0xFF4D444C)
internal val PurpleGray50 = Color(0xFF7F747C)
internal val PurpleGray60 = Color(0xFF998D96)
internal val PurpleGray80 = Color(0xFFD0C2CC)
internal val PurpleGray80 = Color(0xFFD0C3CC)
internal val PurpleGray90 = Color(0xFFEDDEE8)
internal val Red10 = Color(0xFF410001)
internal val Red20 = Color(0xFF680003)
internal val Red30 = Color(0xFF930006)
internal val Red40 = Color(0xFFBA1B1B)
internal val Red80 = Color(0xFFFFB4A9)
internal val Red90 = Color(0xFFFFDAD4)
internal val Red10 = Color(0xFF410002)
internal val Red20 = Color(0xFF690005)
internal val Red30 = Color(0xFF93000A)
internal val Red40 = Color(0xFFBA1A1A)
internal val Red80 = Color(0xFFFFB4AB)
internal val Red90 = Color(0xFFFFDAD6)
internal val Teal10 = Color(0xFF001F26)
internal val Teal20 = Color(0xFF02363F)
internal val Teal30 = Color(0xFF214D56)

@ -22,13 +22,16 @@ import androidx.compose.ui.graphics.Color
/**
* A class to model gradient color values for Now in Android.
*
* @param top The top gradient color to be rendered.
* @param bottom The bottom gradient color to be rendered.
* @param container The container gradient color over which the gradient will be rendered.
*/
@Immutable
data class GradientColors(
val primary: Color = Color.Unspecified,
val secondary: Color = Color.Unspecified,
val tertiary: Color = Color.Unspecified,
val neutral: Color = Color.Unspecified
val top: Color = Color.Unspecified,
val bottom: Color = Color.Unspecified,
val container: Color = Color.Unspecified
)
/**

@ -25,6 +25,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
@ -58,6 +59,8 @@ val LightDefaultColorScheme = lightColorScheme(
onSurface = DarkPurpleGray10,
surfaceVariant = PurpleGray90,
onSurfaceVariant = PurpleGray30,
inverseSurface = DarkPurpleGray20,
inverseOnSurface = DarkPurpleGray95,
outline = PurpleGray50
)
@ -88,6 +91,8 @@ val DarkDefaultColorScheme = darkColorScheme(
onSurface = DarkPurpleGray90,
surfaceVariant = PurpleGray30,
onSurfaceVariant = PurpleGray80,
inverseSurface = DarkPurpleGray90,
inverseOnSurface = DarkPurpleGray10,
outline = PurpleGray60
)
@ -118,6 +123,8 @@ val LightAndroidColorScheme = lightColorScheme(
onSurface = DarkGreenGray10,
surfaceVariant = GreenGray90,
onSurfaceVariant = GreenGray30,
inverseSurface = DarkGreenGray20,
inverseOnSurface = DarkGreenGray95,
outline = GreenGray50
)
@ -148,18 +155,20 @@ val DarkAndroidColorScheme = darkColorScheme(
onSurface = DarkGreenGray90,
surfaceVariant = GreenGray30,
onSurfaceVariant = GreenGray80,
inverseSurface = DarkGreenGray90,
inverseOnSurface = DarkGreenGray10,
outline = GreenGray60
)
/**
* Light default gradient colors
* Light Android gradient colors
*/
val LightDefaultGradientColors = GradientColors(
primary = Purple95,
secondary = Orange95,
tertiary = Blue95,
neutral = DarkPurpleGray95
)
val LightAndroidGradientColors = GradientColors(container = DarkGreenGray95)
/**
* Dark Android gradient colors
*/
val DarkAndroidGradientColors = GradientColors(container = Color.Black)
/**
* Light Android background theme
@ -207,32 +216,37 @@ internal fun NiaTheme(
disableDynamicTheming: Boolean,
content: @Composable () -> Unit
) {
val colorScheme = if (androidTheme) {
if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
} else if (!disableDynamicTheming && supportsDynamicTheming()) {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
// Color scheme
val colorScheme = when {
androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
!disableDynamicTheming && supportsDynamicTheming() -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
val defaultGradientColors = GradientColors()
val gradientColors = if (androidTheme || (!disableDynamicTheming && supportsDynamicTheming())) {
defaultGradientColors
} else {
if (darkTheme) defaultGradientColors else LightDefaultGradientColors
// Gradient colors
val emptyGradientColors = GradientColors(container = colorScheme.surfaceColorAtElevation(2.dp))
val defaultGradientColors = GradientColors(
top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer,
container = colorScheme.surface
)
val gradientColors = when {
androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors
!disableDynamicTheming && supportsDynamicTheming() -> emptyGradientColors
else -> defaultGradientColors
}
// Background theme
val defaultBackgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = if (androidTheme) {
if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
} else {
defaultBackgroundTheme
val backgroundTheme = when {
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme
}
// Composition locals
CompositionLocalProvider(
LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme

@ -18,98 +18,101 @@ package com.google.samples.apps.nowinandroid.core.designsystem.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* Now in Android typography.
*
* TODO: Add custom font
*/
internal val NiaTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.W700,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.W700,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.W500,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.W400,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.W500,
fontWeight = FontWeight.Medium,
fontSize = 10.sp,
lineHeight = 16.sp
lineHeight = 16.sp,
letterSpacing = 0.sp
)
)

@ -18,52 +18,43 @@ 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.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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 javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
/**
* A use case responsible for obtaining news resources with their associated bookmarked (also known
* as "saved") state.
*/
class GetSaveableNewsResourcesUseCase @Inject constructor(
class GetUserNewsResourcesUseCase @Inject constructor(
private val newsRepository: NewsRepository,
userDataRepository: UserDataRepository
private val userDataRepository: UserDataRepository
) {
private val bookmarkedNewsResources = userDataRepository.userData.map {
it.bookmarkedNewsResources
}
/**
* Returns a list of SaveableNewsResources which match the supplied set of topic ids.
* Returns a list of UserNewsResources which match the supplied set of topic ids.
*
* @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
* this is empty the list of news resources will not be filtered.
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet()
): Flow<List<SaveableNewsResource>> =
): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources()
} else {
newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
}.mapToSaveableNewsResources(bookmarkedNewsResources)
}.mapToUserNewsResources(userDataRepository.userData)
}
private fun Flow<List<NewsResource>>.mapToSaveableNewsResources(
savedNewsResourceIds: Flow<Set<String>>
): Flow<List<SaveableNewsResource>> =
private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>
): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() }
.combine(savedNewsResourceIds) { newsResources, savedNewsResourceIds ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResourceIds.contains(newsResource.id)
)
}
.combine(userDataStream) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData)
}

@ -17,11 +17,27 @@
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
/**
* A [topic] with the additional information for whether or not it is followed.
*/
data class FollowableTopic(
data class FollowableTopic( // TODO consider changing to UserTopic and flattening
val topic: Topic,
val isFollowed: Boolean
)
val previewFollowableTopics = listOf(
FollowableTopic(
previewTopics[0],
isFollowed = false
),
FollowableTopic(
previewTopics[1],
isFollowed = true
),
FollowableTopic(
previewTopics[2],
isFollowed = false
)
)

@ -1,27 +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.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/**
* A [NewsResource] with the additional information for whether it is saved.
*/
data class SaveableNewsResource(
val newsResource: NewsResource,
val isSaved: Boolean,
)

@ -0,0 +1,129 @@
/*
* 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.model
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.UserData
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/**
* A [NewsResource] with additional user information such as whether the user is following the
* news resource's topics and whether they have saved (bookmarked) this news resource.
*/
data class UserNewsResource internal constructor(
val id: String,
val title: String,
val content: String,
val url: String,
val headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
val followableTopics: List<FollowableTopic>,
val isSaved: Boolean
) {
constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id,
title = newsResource.title,
content = newsResource.content,
url = newsResource.url,
headerImageUrl = newsResource.headerImageUrl,
publishDate = newsResource.publishDate,
type = newsResource.type,
followableTopics = newsResource.topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = userData.followedTopics.contains(topic.id)
)
},
isSaved = userData.bookmarkedNewsResources.contains(newsResource.id)
)
}
fun List<NewsResource>.mapToUserNewsResources(userData: UserData): List<UserNewsResource> {
return map { UserNewsResource(it, userData) }
}
val previewUserNewsResources = listOf(
UserNewsResource(
id = "1",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0
).toInstant(TimeZone.UTC),
type = Codelab,
followableTopics = listOf(previewFollowableTopics[1]),
isSaved = true
),
UserNewsResource(
id = "2",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
followableTopics = listOf(previewFollowableTopics[0], previewFollowableTopics[1]),
isSaved = false
),
UserNewsResource(
id = "3",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
followableTopics = listOf(previewFollowableTopics[2]),
isSaved = false
),
UserNewsResource(
id = "4",
title = "New Jetpack Release",
content = "New Jetpack release includes updates to libraries such as CameraX, Benchmark, and" +
"more!",
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
followableTopics = listOf(previewFollowableTopics[2]),
isSaved = true
)
)

@ -16,12 +16,13 @@
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
@ -30,7 +31,7 @@ import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test
class GetSaveableNewsResourcesUseCaseTest {
class GetUserNewsResourcesUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@ -38,47 +39,48 @@ class GetSaveableNewsResourcesUseCaseTest {
private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSaveableNewsResourcesUseCase(newsRepository, userDataRepository)
val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository)
@Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the saveable news resources stream.
val saveableNewsResources = useCase()
// Obtain the user news resources stream.
val userNewsResources = useCase()
// Send some news resources and bookmarks.
// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(
setOf(sampleNewsResources[0].id, sampleNewsResources[2].id)
// 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(
SaveableNewsResource(sampleNewsResources[0], true),
SaveableNewsResource(sampleNewsResources[1], false),
SaveableNewsResource(sampleNewsResources[2], true)
),
saveableNewsResources.first()
sampleNewsResources.mapToUserNewsResources(userData),
userNewsResources.first()
)
}
@Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of saveable news resources for the given topic id.
val saveableNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
// Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
// Send some news resources and bookmarks.
// Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(setOf())
userDataRepository.setUserData(emptyUserData)
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.map { SaveableNewsResource(it, false) },
saveableNewsResources.first()
.mapToUserNewsResources(emptyUserData),
userNewsResources.first()
)
}
}

@ -0,0 +1,106 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.datetime.Clock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class UserNewsResourceTest {
/**
* Given: Some user data and news resources
* When: They are combined using `UserNewsResource.from`
* Then: The correct UserNewsResources are constructed
*/
@Test
fun userNewsResourcesAreConstructedFromNewsResourcesAndUserData() {
val newsResource1 = NewsResource(
id = "N1",
title = "Test news title",
content = "Test news content",
url = "Test URL",
headerImageUrl = "Test image URL",
publishDate = Clock.System.now(),
type = Article,
topics = listOf(
Topic(
id = "T1",
name = "Topic 1",
shortDescription = "Topic 1 short description",
longDescription = "Topic 1 long description",
url = "Topic 1 URL",
imageUrl = "Topic 1 image URL"
),
Topic(
id = "T2",
name = "Topic 2",
shortDescription = "Topic 2 short description",
longDescription = "Topic 2 long description",
url = "Topic 2 URL",
imageUrl = "Topic 2 image URL"
),
)
)
val userData = UserData(
bookmarkedNewsResources = setOf("N1"),
followedTopics = setOf("T1"),
themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM,
shouldHideOnboarding = true
)
val userNewsResource = UserNewsResource(newsResource1, userData)
// Check that the simple field mappings have been done correctly.
assertEquals(newsResource1.id, userNewsResource.id)
assertEquals(newsResource1.title, userNewsResource.title)
assertEquals(newsResource1.content, userNewsResource.content)
assertEquals(newsResource1.url, userNewsResource.url)
assertEquals(newsResource1.headerImageUrl, userNewsResource.headerImageUrl)
assertEquals(newsResource1.publishDate, userNewsResource.publishDate)
// Check that each Topic has been converted to a FollowedTopic correctly.
assertEquals(newsResource1.topics.size, userNewsResource.followableTopics.size)
for (topic in newsResource1.topics) {
// Construct the expected FollowableTopic.
val followableTopic = FollowableTopic(
topic = topic,
isFollowed = userData.followedTopics.contains(topic.id)
)
assertTrue(userNewsResource.followableTopics.contains(followableTopic))
}
// Check that the saved flag is set correctly.
assertEquals(
userData.bookmarkedNewsResources.contains(newsResource1.id),
userNewsResource.isSaved
)
}
}

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.model.data
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 kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
@ -83,5 +84,16 @@ val previewNewsResources = listOf(
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(previewTopics[2])
),
NewsResource(
id = "4",
title = "New Jetpack Release",
content = "New Jetpack release includes updates to libraries such as CameraX, Benchmark, and" +
"more!",
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
topics = listOf(previewTopics[2])
)
)

@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterNotNull
private val emptyUserData = UserData(
val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
@ -98,4 +98,11 @@ class TestUserDataRepository : UserDataRepository {
*/
fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics
/**
* A test-only API to allow setting of user data directly.
*/
fun setUserData(userData: UserData) {
_userData.tryEmit(userData)
}
}

@ -20,6 +20,9 @@ plugins {
}
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.ui"
}
@ -28,6 +31,7 @@ dependencies {
implementation(project(":core:model"))
implementation(project(":core:domain"))
implementation(libs.androidx.browser)
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt)
implementation(libs.coil.kt.compose)
@ -44,4 +48,6 @@ dependencies {
api(libs.androidx.compose.runtime.livedata)
api(libs.androidx.metrics)
api(libs.androidx.tracing.ktx)
}
androidTestImplementation(project(":core:testing"))
}

@ -0,0 +1,99 @@
/*
* 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.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.domain.model.previewFollowableTopics
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import org.junit.Rule
import org.junit.Test
class NewsResourceCardTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun testMetaDataDisplay_withCodelabResource() {
val newsWithKnownResourceType = previewUserNewsResources[0]
var dateFormatted = ""
composeTestRule.setContent {
NewsResourceCardExpanded(
userNewsResource = newsWithKnownResourceType,
isBookmarked = false,
onToggleBookmark = {},
onClick = {}
)
dateFormatted = dateFormatted(publishDate = newsWithKnownResourceType.publishDate)
}
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(
R.string.card_meta_data_text,
dateFormatted,
newsWithKnownResourceType.type.displayText
)
)
.assertExists()
}
@Test
fun testMetaDataDisplay_withUnknownResource() {
val newsWithUnknownResourceType = previewUserNewsResources[3]
var dateFormatted = ""
composeTestRule.setContent {
NewsResourceCardExpanded(
userNewsResource = newsWithUnknownResourceType,
isBookmarked = false,
onToggleBookmark = {},
onClick = {}
)
dateFormatted = dateFormatted(publishDate = newsWithUnknownResourceType.publishDate)
}
composeTestRule
.onNodeWithText(dateFormatted)
.assertIsDisplayed()
}
@Test
fun testTopicsChipColorBackground_matchesFollowedState() {
composeTestRule.setContent {
NewsResourceTopics(topics = previewFollowableTopics)
}
for (followableTopic in previewFollowableTopics) {
val topicName = followableTopic.topic.name
val expectedContentDescription = if (followableTopic.isFollowed) {
"$topicName is followed"
} else {
"$topicName is not followed"
}
composeTestRule
.onNodeWithText(topicName.uppercase())
.assertContentDescriptionEquals(expectedContentDescription)
}
}
}

@ -16,25 +16,29 @@
package com.google.samples.apps.nowinandroid.core.ui
import android.content.Intent
import android.content.Context
import android.net.Uri
import androidx.annotation.ColorInt
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
/**
* An extension on [LazyListScope] defining a feed with news resources.
@ -47,21 +51,21 @@ fun LazyGridScope.newsFeed(
when (feedState) {
NewsFeedUiState.Loading -> Unit
is NewsFeedUiState.Success -> {
items(feedState.feed, key = { it.newsResource.id }) { saveableNewsResource ->
items(feedState.feed, key = { it.id }) { userNewsResource ->
val resourceUrl by remember {
mutableStateOf(Uri.parse(saveableNewsResource.newsResource.url))
mutableStateOf(Uri.parse(userNewsResource.url))
}
val launchResourceIntent = Intent(Intent.ACTION_VIEW, resourceUrl)
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
NewsResourceCardExpanded(
newsResource = saveableNewsResource.newsResource,
isBookmarked = saveableNewsResource.isSaved,
onClick = { ContextCompat.startActivity(context, launchResourceIntent, null) },
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) },
onToggleBookmark = {
onNewsResourcesCheckedChanged(
saveableNewsResource.newsResource.id,
!saveableNewsResource.isSaved
userNewsResource.id,
!userNewsResource.isSaved
)
}
)
@ -70,6 +74,16 @@ fun LazyGridScope.newsFeed(
}
}
fun launchCustomChromeTab(context: Context, uri: Uri, @ColorInt toolbarColor: Int) {
val customTabBarColor = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor).build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(customTabBarColor)
.build()
customTabsIntent.launchUrl(context, uri)
}
/**
* A sealed hierarchy describing the state of the feed of news resources.
*/
@ -86,13 +100,13 @@ sealed interface NewsFeedUiState {
/**
* The list of news resources contained in this feed.
*/
val feed: List<SaveableNewsResource>
val feed: List<UserNewsResource>
) : NewsFeedUiState
}
@Preview
@Composable
fun NewsFeedLoadingPreview() {
private fun NewsFeedLoadingPreview() {
NiaTheme {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
@ -106,17 +120,12 @@ fun NewsFeedLoadingPreview() {
@Preview
@Preview(device = Devices.TABLET)
@Composable
fun NewsFeedContentPreview() {
private fun NewsFeedContentPreview() {
NiaTheme {
LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) {
newsFeed(
feedState = NewsFeedUiState.Success(
previewNewsResources.map {
SaveableNewsResource(
it,
false
)
}
previewUserNewsResources
),
onNewsResourcesCheckedChanged = { _, _ -> }
)

@ -46,19 +46,22 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
@ -72,7 +75,7 @@ import kotlinx.datetime.toJavaInstant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsResourceCardExpanded(
newsResource: NewsResource,
userNewsResource: UserNewsResource,
isBookmarked: Boolean,
onToggleBookmark: () -> Unit,
onClick: () -> Unit,
@ -90,9 +93,9 @@ fun NewsResourceCardExpanded(
}
) {
Column {
if (!newsResource.headerImageUrl.isNullOrEmpty()) {
if (!userNewsResource.headerImageUrl.isNullOrEmpty()) {
Row {
NewsResourceHeaderImage(newsResource.headerImageUrl)
NewsResourceHeaderImage(userNewsResource.headerImageUrl)
}
}
Box(
@ -102,18 +105,18 @@ fun NewsResourceCardExpanded(
Spacer(modifier = Modifier.height(12.dp))
Row {
NewsResourceTitle(
newsResource.title,
userNewsResource.title,
modifier = Modifier.fillMaxWidth((.8f))
)
Spacer(modifier = Modifier.weight(1f))
BookmarkButton(isBookmarked, onToggleBookmark)
}
Spacer(modifier = Modifier.height(12.dp))
NewsResourceDate(newsResource.publishDate)
NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type)
Spacer(modifier = Modifier.height(12.dp))
NewsResourceShortDescription(newsResource.content)
NewsResourceShortDescription(userNewsResource.content)
Spacer(modifier = Modifier.height(12.dp))
NewsResourceTopics(newsResource.topics)
NewsResourceTopics(userNewsResource.followableTopics)
}
}
}
@ -155,7 +158,7 @@ fun BookmarkButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
NiaToggleButton(
NiaIconToggleButton(
checked = isBookmarked,
onCheckedChange = { onClick() },
modifier = modifier,
@ -175,7 +178,7 @@ fun BookmarkButton(
}
@Composable
private fun dateFormatted(publishDate: Instant): String {
fun dateFormatted(publishDate: Instant): String {
var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) }
val context = LocalContext.current
@ -195,14 +198,24 @@ private fun dateFormatted(publishDate: Instant): String {
}
@Composable
fun NewsResourceDate(
publishDate: Instant
fun NewsResourceMetaData(
publishDate: Instant,
resourceType: NewsResourceType
) {
Text(dateFormatted(publishDate), style = MaterialTheme.typography.labelSmall)
val formattedDate = dateFormatted(publishDate)
Text(
if (resourceType != NewsResourceType.Unknown) {
stringResource(R.string.card_meta_data_text, formattedDate, resourceType.displayText)
} else {
formattedDate
},
style = MaterialTheme.typography.labelSmall
)
}
@Composable
fun NewsResourceLink(
@Suppress("UNUSED_PARAMETER")
newsResource: NewsResource
) {
TODO()
@ -217,7 +230,7 @@ fun NewsResourceShortDescription(
@Composable
fun NewsResourceTopics(
topics: List<Topic>,
topics: List<FollowableTopic>,
modifier: Modifier = Modifier
) {
// Store the ID of the Topic which has its "following" menu expanded, if any.
@ -228,17 +241,35 @@ fun NewsResourceTopics(
modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
for (topic in topics) {
for (followableTopic in topics) {
NiaTopicTag(
expanded = expandedTopicId == topic.id,
followed = true, // ToDo: Check if topic is followed
onDropMenuToggle = { show ->
expandedTopicId = if (show) topic.id else null
expanded = expandedTopicId == followableTopic.topic.id,
followed = followableTopic.isFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) followableTopic.topic.id else null
},
onFollowClick = { }, // ToDo
onUnfollowClick = { }, // ToDo
onBrowseClick = { }, // ToDo
text = { Text(text = topic.name.uppercase(Locale.getDefault())) }
text = {
val contentDescription = if (followableTopic.isFollowed) {
stringResource(
R.string.topic_chip_content_description_when_followed,
followableTopic.topic.name
)
} else {
stringResource(
R.string.topic_chip_content_description_when_not_followed,
followableTopic.topic.name
)
}
Text(
text = followableTopic.topic.name.uppercase(Locale.getDefault()),
modifier = Modifier.semantics {
this.contentDescription = contentDescription
}
)
}
)
}
}
@ -246,7 +277,7 @@ fun NewsResourceTopics(
@Preview("Bookmark Button")
@Composable
fun BookmarkButtonPreview() {
private fun BookmarkButtonPreview() {
NiaTheme {
Surface {
BookmarkButton(isBookmarked = false, onClick = { })
@ -256,7 +287,7 @@ fun BookmarkButtonPreview() {
@Preview("Bookmark Button Bookmarked")
@Composable
fun BookmarkButtonBookmarkedPreview() {
private fun BookmarkButtonBookmarkedPreview() {
NiaTheme {
Surface {
BookmarkButton(isBookmarked = true, onClick = { })
@ -266,11 +297,11 @@ fun BookmarkButtonBookmarkedPreview() {
@Preview("NewsResourceCardExpanded")
@Composable
fun ExpandedNewsResourcePreview() {
private fun ExpandedNewsResourcePreview() {
NiaTheme {
Surface {
NewsResourceCardExpanded(
newsResource = previewNewsResources[0],
userNewsResource = previewUserNewsResources[0],
isBookmarked = true,
onToggleBookmark = {},
onClick = {}

@ -16,49 +16,44 @@
package com.google.samples.apps.nowinandroid.core.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
/**
* Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a generic
* [List] [T].
* Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of
* [UserNewsResource]s.
*
* [newsResourceMapper] maps type [T] to a [NewsResource]
* [isBookmarkedMapper] maps type [T] to whether the [NewsResource] is bookmarked
* [onToggleBookmark] defines the action invoked when a user wishes to bookmark an item
* [onItemClick] optional parameter for action to be performed when the card is clicked. The
* default action launches an intent matching the card.
*/
fun <T> LazyListScope.newsResourceCardItems(
items: List<T>,
newsResourceMapper: (item: T) -> NewsResource,
isBookmarkedMapper: (item: T) -> Boolean,
onToggleBookmark: (item: T) -> Unit,
onItemClick: ((item: T) -> Unit)? = null,
fun LazyListScope.userNewsResourceCardItems(
items: List<UserNewsResource>,
onToggleBookmark: (item: UserNewsResource) -> Unit,
onItemClick: ((item: UserNewsResource) -> Unit)? = null,
itemModifier: Modifier = Modifier,
) = items(
items = items,
key = { newsResourceMapper(it).id },
itemContent = { item ->
val newsResource = newsResourceMapper(item)
val launchResourceIntent =
Intent(Intent.ACTION_VIEW, Uri.parse(newsResource.url))
key = { it.id },
itemContent = { userNewsResource ->
val resourceUrl = Uri.parse(userNewsResource.url)
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val context = LocalContext.current
NewsResourceCardExpanded(
newsResource = newsResource,
isBookmarked = isBookmarkedMapper(item),
onToggleBookmark = { onToggleBookmark(item) },
userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved,
onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = {
when (onItemClick) {
null -> ContextCompat.startActivity(context, launchResourceIntent, null)
else -> onItemClick(item)
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)
else -> onItemClick(userNewsResource)
}
},
modifier = itemModifier

@ -20,4 +20,8 @@
<string name="back">Back</string>
<string name="card_tap_action">Open Resource Link</string>
<string name="card_meta_data_text">%1$s • %2$s</string>
<string name="topic_chip_content_description_when_followed">%1$s is followed</string>
<string name="topic_chip_content_description_when_not_followed">%1$s is not followed</string>
</resources>

@ -18,7 +18,7 @@ The goals for the app architecture are:
## Architecture overview
The app architecture has two layers: a [data layer](https://developer.android.com/jetpack/guide/data-layer) and [UI layer](https://developer.android.com/jetpack/guide/ui-layer) (a third, [the domain layer](https://developer.android.com/jetpack/guide/domain-layer), is currently in development).
The app architecture has three layers: a [data layer](https://developer.android.com/jetpack/guide/data-layer), a [domain layer](https://developer.android.com/jetpack/guide/domain-layer) and a [UI layer](https://developer.android.com/jetpack/guide/ui-layer).
<center>
@ -39,7 +39,7 @@ The data flow is achieved using streams, implemented using [Kotlin Flows](https:
### Example: Displaying news on the For You screen
When the app is first run it will attempt to load a list of news resources from a remote server (when the `staging` or `release` build variant is selected, `debug` builds will use local data). Once loaded, these are shown to the user based on the interests they choose.
When the app is first run it will attempt to load a list of news resources from a remote server (when the `prod` build flavor is selected, `demo` builds will use local data). Once loaded, these are shown to the user based on the interests they choose.
The following diagram shows the events which occur and how data flows from the relevant objects to achieve this.
@ -70,7 +70,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
<tr>
<td>2
</td>
<td>The initial news feed state is set to <code>Loading</code>, which causes the UI to show a loading spinner on the screen.
<td>The <code>ForYouViewModel</code> calls <code>GetSaveableNewsResourcesUseCase</code> to obtain a stream of news resources with their bookmarked/saved state. No items will be emitted into this stream until both the user and news repositories emit an item. While waiting, the feed state is set to <code>Loading</code>.
</td>
<td>Search for usages of <code>NewsFeedUiState.Loading</code>
</td>
@ -78,13 +78,21 @@ Here's what's happening in each step. The easiest way to find the associated cod
<tr>
<td>3
</td>
<td>The user data repository obtains a stream of <code>UserData</code> objects from a local data source backed by Proto DataStore.
</td>
<td><code>NiaPreferencesDataSource.userData</code>
</td>
</tr>
<tr>
<td>4
</td>
<td>WorkManager executes the sync job which calls <code>OfflineFirstNewsRepository</code> to start synchronizing data with the remote data source.
</td>
<td><code>SyncWorker.doWork</code>
</td>
</tr>
<tr>
<td>4
<td>5
</td>
<td><code>OfflineFirstNewsRepository</code> calls <code>RetrofitNiaNetwork</code> to execute the actual API request using <a href="https://square.github.io/retrofit/">Retrofit</a>.
</td>
@ -92,7 +100,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>5
<td>6
</td>
<td><code>RetrofitNiaNetwork</code> calls the REST API on the remote server.
</td>
@ -100,7 +108,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>6
<td>7
</td>
<td><code>RetrofitNiaNetwork</code> receives the network response from the remote server.
</td>
@ -108,7 +116,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>7
<td>8
</td>
<td><code>OfflineFirstNewsRepository</code> syncs the remote data with <code>NewsResourceDao</code> by inserting, updating or deleting data in a local <a href="https://developer.android.com/training/data-storage/room">Room database</a>.
</td>
@ -116,7 +124,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>8
<td>9
</td>
<td>When data changes in <code>NewsResourceDao</code> it is emitted into the news resources data stream (which is a <a href="https://developer.android.com/kotlin/flow">Flow</a>).
</td>
@ -124,7 +132,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>9
<td>10
</td>
<td><code>OfflineFirstNewsRepository</code> acts as an <a href="https://developer.android.com/kotlin/flow#modify">intermediate operator</a> on this stream, transforming the incoming <code>PopulatedNewsResource</code> (a database model, internal to the data layer) to the public <code>NewsResource</code> model which is consumed by other layers.
</td>
@ -132,11 +140,19 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
</tr>
<tr>
<td>10
<td>11
</td>
<td><code>GetSaveableNewsResourcesUseCase</code> combines the list of news resources with the user data to emit a list of <code>SaveableNewsResource</code>s.
</td>
<td><code>GetSaveableNewsResourcesUseCase.invoke</code>
</td>
<td><code>When ForYouViewModel</code> receives the news resources it updates the feed state to <code>Success</code>. <code>ForYouScreen</code> then uses the news resources in the state to render the screen.
<p>
The screen shows the newly retrieved news resources (as long as the user has chosen at least one topic).
</tr>
<tr>
<td>12
</td>
<td>When <code>ForYouViewModel</code> receives the saveable news resources it updates the feed state to <code>Success</code>.
<code>ForYouScreen</code> then uses the saveable news resources in the state to render the screen.
</td>
<td>Search for instances of <code>NewsFeedUiState.Success</code>
</td>
@ -233,6 +249,14 @@ In the case of errors during data synchronization, an exponential backoff strate
See the `OfflineFirstNewsRepository.syncWith` for an example of data synchronization.
## Domain layer
The [domain layer](https://developer.android.com/topic/architecture/domain-layer) contains use cases. These are classes which have a single invocable method (`operator fun invoke`) containing business logic.
These use cases are used to simplify and remove duplicate logic from ViewModels. They typically combine and transform data from repositories.
For example, `GetSaveableNewsResourcesUseCase` combines a stream (implemented using `Flow`) of `NewsResource`s from a `NewsRepository` with a stream of `UserData` objects from a `UserDataRepository` to create a stream of `SaveableNewsResource`s. This stream is used by various ViewModels to display news resources on screen with their bookmarked state.
Notably, the domain layer in Now in Android _does not_ (for now) contain any use cases for event handling. Events are handled by the UI layer calling methods on repositories directly.
## UI Layer
@ -243,7 +267,7 @@ The [UI layer](https://developer.android.com/topic/architecture/ui-layer) compri
* UI elements built using [Jetpack Compose](https://developer.android.com/jetpack/compose)
* [Android ViewModels](https://developer.android.com/topic/libraries/architecture/viewmodel)
The ViewModels receive streams of data from repositories and transform them into UI state. The UI elements reflect this state, and provide ways for the user to interact with the app. These interactions are passed as events to the view model where they are processed.
The ViewModels receive streams of data from use cases and repositories, and transforms them into UI state. The UI elements reflect this state, and provide ways for the user to interact with the app. These interactions are passed as events to the ViewModel where they are processed.
![Diagram showing the UI layer architecture](images/architecture-4-ui-layer.png "Diagram showing the UI layer architecture")
@ -272,29 +296,20 @@ The `feedState` is passed to the `ForYouScreen` composable, which handles both o
### Transforming streams into UI state
View models receive streams of data as cold [flows](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html) from one or more repositories. These are [combined](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html) together to produce a single flow of UI state. This single flow is then converted to a hot flow using [stateIn](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html). The conversion to a state flow enables UI elements to read the last known state from the flow.
ViewModels receive streams of data as cold [flows](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html) from one or more use cases or repositories. These are [combined](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html) together, or simply [mapped](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/map.html), to produce a single flow of UI state. This single flow is then converted to a hot flow using [stateIn](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html). The conversion to a state flow enables UI elements to read the last known state from the flow.
**Example: Displaying followed topics**
The `InterestsViewModel` exposes `uiState` as a `StateFlow<InterestsUiState>`. This hot flow is created by combining two data streams:
* List of topics
* List of topic IDs which the current user is following
The list of `Topic`s is mapped to a new list of `FollowableTopic`s. `FollowableTopic` is a wrapper for `Topic` which also indicates whether the current user is following that topic.
The new list is used to create a `InterestsUiState.Interests` state which is exposed to the UI.
The `InterestsViewModel` exposes `uiState` as a `StateFlow<InterestsUiState>`. This hot flow is created by obtaining the cold flow of `List<FollowableTopic>` provided by `GetFollowableTopicsUseCase`. Each time a new list is emitted, it is converted into an `InterestsUiState.Interests` state which is exposed to the UI.
### Processing user interactions
User actions are communicated from UI elements to view models using regular method invocations. These methods are passed to the UI elements as lambda expressions.
User actions are communicated from UI elements to ViewModels using regular method invocations. These methods are passed to the UI elements as lambda expressions.
**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 view model 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 topics repository.
## Further reading

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

After

Width:  |  Height:  |  Size: 245 KiB

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
@ -21,6 +24,20 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}
dependencies {

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.activity.ComponentActivity
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.filter
@ -31,8 +30,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@ -65,13 +63,10 @@ class BookmarksScreenTest {
@Test
fun feed_whenHasBookmarks_showsBookmarks() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
previewUserNewsResources.take(2)
),
removeFromBookmarks = { }
)
@ -79,7 +74,7 @@ class BookmarksScreenTest {
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
.assertExists()
@ -88,14 +83,14 @@ class BookmarksScreenTest {
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
.assertExists()
@ -109,11 +104,10 @@ class BookmarksScreenTest {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
previewUserNewsResources.take(2)
),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewNewsResources[0].id, newsResourceId)
assertEquals(previewUserNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
}
)
@ -127,7 +121,7 @@ class BookmarksScreenTest {
).filter(
hasAnyAncestor(
hasText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
)
@ -138,4 +132,26 @@ class BookmarksScreenTest {
assertTrue(removeFromBookmarksCalled)
}
@Test
fun feed_whenHasNoBookmarks_showsEmptyState() {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()),
removeFromBookmarks = { }
)
}
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.bookmarks_empty_error)
)
.assertExists()
composeTestRule
.onNodeWithText(
composeTestRule.activity.getString(R.string.bookmarks_empty_description)
)
.assertExists()
}
}

@ -52,8 +52,7 @@ import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
@ -184,9 +183,7 @@ private fun BookmarksGridPreview() {
NiaTheme {
BookmarksGrid(
feedState = Success(
previewNewsResources.map {
SaveableNewsResource(it, false)
}
previewUserNewsResources
),
removeFromBookmarks = {}
)
@ -195,7 +192,7 @@ private fun BookmarksGridPreview() {
@Preview
@Composable
fun EmptyStatePreview() {
private fun EmptyStatePreview() {
NiaTheme {
EmptyState()
}

@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel
@ -36,13 +36,13 @@ import kotlinx.coroutines.launch
@HiltViewModel
class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
getSaveableNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() {
val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources()
.filterNot { it.isEmpty() }
.map { newsResources -> newsResources.filter(SaveableNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
.stateIn(
scope = viewModelScope,

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -43,7 +43,7 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -53,7 +53,7 @@ class BookmarksViewModelTest {
fun setup() {
viewModel = BookmarksViewModel(
userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase
getSaveableNewsResources = getUserNewsResourcesUseCase
)
}

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
@ -21,6 +24,20 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.foryou"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}
dependencies {

@ -29,9 +29,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import org.junit.Rule
import org.junit.Test
@ -225,9 +224,7 @@ class ForYouScreenTest {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -237,7 +234,7 @@ class ForYouScreenTest {
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
previewUserNewsResources[0].title,
substring = true
)
.assertExists()
@ -246,14 +243,14 @@ class ForYouScreenTest {
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
previewUserNewsResources[1].title,
substring = true
)
.assertExists()

@ -77,14 +77,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -256,7 +255,7 @@ private fun LazyGridScope.onboarding(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
NiaFilledButton(
NiaButton(
onClick = saveFollowedTopics,
enabled = onboardingUiState.isDismissable,
modifier = Modifier
@ -352,7 +351,7 @@ private fun SingleTopicButton(
.weight(1f),
color = MaterialTheme.colorScheme.onSurface
)
NiaToggleButton(
NiaIconToggleButton(
checked = isSelected,
onCheckedChange = { checked -> onClick(topicId, checked) },
icon = {
@ -398,9 +397,7 @@ fun ForYouScreenPopulatedFeed() {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -419,9 +416,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -442,9 +437,7 @@ fun ForYouScreenTopicSelection() {
topics = previewTopics.map { FollowableTopic(it, false) },
),
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
@ -480,9 +473,7 @@ fun ForYouScreenPopulatedAndLoading() {
isSyncing = true,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
feed = previewUserNewsResources
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},

@ -21,8 +21,8 @@ import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
private val getSaveableNewsResources: GetUserNewsResourcesUseCase,
getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() {
@ -117,6 +117,6 @@ class ForYouViewModel @Inject constructor(
}
}
private fun Flow<List<SaveableNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
private fun Flow<List<UserNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(NewsFeedUiState.Loading) }

@ -17,15 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor
@ -54,7 +56,7 @@ class ForYouViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -69,7 +71,7 @@ class ForYouViewModelTest {
viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase,
getSaveableNewsResources = getUserNewsResourcesUseCase,
getFollowableTopics = getFollowableTopicsUseCase
)
}
@ -263,7 +265,10 @@ class ForYouViewModelTest {
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1"))
val followedTopicIds = setOf("0", "1")
val userData = emptyUserData.copy(followedTopics = followedTopicIds)
userDataRepository.setUserData(userData)
viewModel.dismissOnboarding()
assertEquals(
@ -280,13 +285,7 @@ class ForYouViewModelTest {
)
assertEquals(
NewsFeedUiState.Success(
feed =
sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
isSaved = false
)
}
feed = sampleNewsResources.mapToUserNewsResources(userData)
),
viewModel.feedState.value
)
@ -307,41 +306,9 @@ class ForYouViewModelTest {
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
topics = sampleTopics.map {
FollowableTopic(it, false)
}
),
viewModel.onboardingUiState.value
)
@ -352,59 +319,25 @@ class ForYouViewModelTest {
viewModel.feedState.value
)
viewModel.updateTopicSelection("1", isChecked = true)
val followedTopicId = sampleTopics[1].id
viewModel.updateTopicSelection(followedTopicId, isChecked = true)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
topics = sampleTopics.map {
FollowableTopic(it, it.id == followedTopicId)
}
),
viewModel.onboardingUiState.value
)
val userData = emptyUserData.copy(followedTopics = setOf(followedTopicId))
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
UserNewsResource(sampleNewsResources[1], userData),
UserNewsResource(sampleNewsResources[2], userData),
)
),
viewModel.feedState.value
@ -484,11 +417,25 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val followedTopicIds = setOf("1")
val userData = emptyUserData.copy(
followedTopics = followedTopicIds,
shouldHideOnboarding = true
)
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1"))
userDataRepository.setShouldHideOnboarding(true)
userDataRepository.setUserData(userData)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true)
val bookmarkedNewsResourceId = "2"
viewModel.updateNewsResourceSaved(
newsResourceId = bookmarkedNewsResourceId,
isChecked = true
)
val userDataExpected = userData.copy(
bookmarkedNewsResources = setOf(bookmarkedNewsResourceId)
)
assertEquals(
OnboardingUiState.NotShown,
@ -497,14 +444,8 @@ class ForYouViewModelTest {
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = true
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
UserNewsResource(newsResource = sampleNewsResources[1], userDataExpected),
UserNewsResource(newsResource = sampleNewsResources[2], userDataExpected)
)
),
viewModel.feedState.value

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
@ -20,4 +23,18 @@ plugins {
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.interests"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}

@ -52,12 +52,12 @@ class InterestsScreenTest {
@Before
fun setup() {
composeTestRule.activity.apply {
interestsLoading = getString(R.string.interests_loading)
interestsEmptyHeader = getString(R.string.interests_empty_header)
interestsLoading = getString(R.string.loading)
interestsEmptyHeader = getString(R.string.empty_header)
interestsTopicCardFollowButton =
getString(R.string.interests_card_follow_button_content_desc)
getString(R.string.card_follow_button_content_desc)
interestsTopicCardUnfollowButton =
getString(R.string.interests_card_unfollow_button_content_desc)
getString(R.string.card_unfollow_button_content_desc)
}
}

@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
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.feature.interests.R.string
@ -69,14 +69,14 @@ fun InterestsItem(
Spacer(modifier = Modifier.width(16.dp))
InterestContent(name, description)
}
NiaToggleButton(
NiaIconToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.interests_card_follow_button_content_desc
id = string.card_follow_button_content_desc
)
)
},
@ -84,7 +84,7 @@ fun InterestsItem(
Icon(
imageVector = NiaIcons.Check,
contentDescription = stringResource(
id = string.interests_card_unfollow_button_content_desc
id = string.card_unfollow_button_content_desc
)
)
}

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -51,7 +50,6 @@ internal fun InterestsRoute(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InterestsScreen(
uiState: InterestsUiState,
@ -67,7 +65,7 @@ internal fun InterestsScreen(
InterestsUiState.Loading ->
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = R.string.interests_loading),
contentDesc = stringResource(id = R.string.loading),
)
is InterestsUiState.Interests ->
TopicsTabContent(
@ -83,7 +81,7 @@ internal fun InterestsScreen(
@Composable
private fun InterestsEmptyScreen() {
Text(text = stringResource(id = R.string.interests_empty_header))
Text(text = stringResource(id = R.string.empty_header))
}
@DevicePreviews

@ -24,10 +24,8 @@ import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -38,14 +36,6 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
private val _tabState = MutableStateFlow(
InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
)
)
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests
@ -62,11 +52,6 @@ class InterestsViewModel @Inject constructor(
}
}
data class InterestsTabState(
val titles: List<Int>,
val currentIndex: Int
)
sealed interface InterestsUiState {
object Loading : InterestsUiState

@ -15,15 +15,12 @@
limitations under the License.
-->
<resources>
<!-- TODO: Remove the redundant "interests" prefix -->
<string name="interests">Interests</string>
<string name="interests_topics">Topics</string>
<string name="interests_people">People</string>
<string name="interests_loading">Loading data</string>
<string name="interests_empty_header">"No available data"</string>
<string name="interests_card_follow_button_content_desc">Follow interest button</string>
<string name="interests_card_unfollow_button_content_desc">Unfollow interest button</string>
<string name="interests_top_app_bar_title">Interests</string>
<string name="interests_top_app_bar_action_menu">Menu</string>
<string name="interests_top_app_bar_action_seearch">Search</string>
<string name="loading">Loading data</string>
<string name="empty_header">"No available data"</string>
<string name="card_follow_button_content_desc">Follow interest button</string>
<string name="card_unfollow_button_content_desc">Unfollow interest button</string>
<string name="top_app_bar_title">Interests</string>
<string name="top_app_bar_action_menu">Menu</string>
<string name="top_app_bar_action_search">Search</string>
</resources>

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
@ -21,4 +24,18 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.settings"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}

@ -255,7 +255,7 @@ private fun TextLink(text: String, url: String) {
@Preview
@Composable
fun PreviewSettingsDialog() {
private fun PreviewSettingsDialog() {
NiaTheme {
SettingsDialog(
onDismiss = {},
@ -273,7 +273,7 @@ fun PreviewSettingsDialog() {
@Preview
@Composable
fun PreviewSettingsDialogLoading() {
private fun PreviewSettingsDialogLoading() {
NiaTheme {
SettingsDialog(
onDismiss = {},

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
@ -21,6 +24,20 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.feature.topic"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
}
dependencies {

@ -25,10 +25,11 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
@ -99,14 +100,7 @@ class TopicScreenTest {
composeTestRule.setContent {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
newsUiState = NewsUiState.Success(sampleUserNewsResources),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
@ -126,12 +120,7 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
sampleUserNewsResources
),
onBackClick = { },
onFollowClick = { },
@ -143,7 +132,7 @@ class TopicScreenTest {
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(hasText(sampleNewsResources.first().title))
.performScrollToNode(hasText(sampleUserNewsResources.first().title))
}
}
@ -188,27 +177,31 @@ private val testTopics = listOf(
)
)
private val sampleNewsResources = listOf(
NewsResource(
id = "1",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
private val sampleUserNewsResources = listOf(
UserNewsResource(
newsResource =
NewsResource(
id = "1",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and" +
" everything the Android Developers YouTube channel has to offer. During the " +
"Android Developer Summit, our YouTube channel reached 1 million subscribers!" +
" Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = TOPIC_DESC,
url = "",
imageUrl = ""
)
)
)
),
userData = emptyUserData.copy(bookmarkedNewsResources = setOf("1"))
)
)

@ -53,12 +53,11 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadi
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@ -146,7 +145,7 @@ private fun LazyListScope.TopicBody(
TopicHeader(name, description, imageUrl)
}
TopicCards(news, onBookmarkChanged)
userNewsResourceCards(news, onBookmarkChanged)
}
@Composable
@ -174,17 +173,16 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
}
}
private fun LazyListScope.TopicCards(
// TODO: Could/should this be replaced with [LazyGridScope.newsFeed]?
private fun LazyListScope.userNewsResourceCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
when (news) {
is NewsUiState.Success -> {
newsResourceCardItems(
userNewsResourceCardItems(
items = news.news,
newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp)
)
}
@ -257,12 +255,7 @@ fun TopicScreenPopulated() {
TopicScreen(
topicUiState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)),
newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
previewUserNewsResources
),
onBackClick = {},
onFollowClick = {},

@ -22,9 +22,9 @@ import androidx.lifecycle.viewModelScope
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.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
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.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
@ -45,8 +45,7 @@ class TopicViewModel @Inject constructor(
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
// newsRepository: NewsRepository,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
getSaveableNewsResources: GetUserNewsResourcesUseCase
) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
@ -97,13 +96,13 @@ private fun topicUiState(
.map { it.followedTopics }
// Observe topic information
val topic: Flow<Topic> = topicsRepository.getTopic(
val topicStream: Flow<Topic> = topicsRepository.getTopic(
id = topicId
)
return combine(
followedTopicIds,
topic,
topicStream,
::Pair
)
.asResult()
@ -131,11 +130,11 @@ private fun topicUiState(
private fun newsUiState(
topicId: String,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val news: Flow<List<SaveableNewsResource>> = getSaveableNewsResources(
val newsStream: Flow<List<UserNewsResource>> = getSaveableNewsResources(
filterTopicIds = setOf(element = topicId),
)
@ -144,7 +143,7 @@ private fun newsUiState(
.map { it.bookmarkedNewsResources }
return combine(
news,
newsStream,
bookmark,
::Pair
)
@ -152,7 +151,7 @@ private fun newsUiState(
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> {
val (news, bookmarks) = newsToBookmarksResult.data
val news = newsToBookmarksResult.data.first
NewsUiState.Success(news)
}
is Result.Loading -> {
@ -172,7 +171,7 @@ sealed interface TopicUiState {
}
sealed interface NewsUiState {
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
data class Success(val news: List<UserNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -53,7 +53,7 @@ class TopicViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
@ -66,7 +66,7 @@ class TopicViewModelTest {
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase
getSaveableNewsResources = getUserNewsResourcesUseCase
)
}

@ -1,11 +1,12 @@
[versions]
accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.0"
androidGradlePlugin = "7.3.1"
androidDesugarJdkLibs = "1.2.2"
androidGradlePlugin = "7.4.0"
androidxActivity = "1.6.1"
androidxAppCompat = "1.5.1"
androidxComposeBom = "2022.11.00"
androidxComposeCompiler = "1.3.2"
androidxBrowser = "1.4.0"
androidxComposeBom = "2022.12.00"
androidxComposeCompiler = "1.4.0-alpha02"
androidxComposeRuntimeTracing = "1.0.0-alpha01"
androidxCore = "1.9.0"
androidxCoreSplashscreen = "1.0.0"
@ -14,35 +15,35 @@ androidxEspresso = "3.5.0"
androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.0-alpha03"
androidxMacroBenchmark = "1.1.1"
androidxNavigation = "2.5.3"
androidxMetrics = "1.0.0-alpha03"
androidxProfileinstaller = "1.2.0"
androidxNavigation = "2.5.3"
androidxProfileinstaller = "1.2.1"
androidxStartup = "1.1.1"
androidxWindowManager = "1.0.0"
androidxTestCore = "1.5.0"
androidxTestExt = "1.1.4"
androidxTestRunner = "1.5.1"
androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.1"
androidxTracing = "1.1.0"
androidxUiAutomator = "2.2.0"
androidxWindowManager = "1.0.0"
androidxWork = "2.7.1"
coil = "2.2.2"
hilt = "2.44.2"
hiltExt = "1.0.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
kotlin = "1.7.20"
kotlin = "1.7.21"
kotlinxCoroutines = "1.6.4"
kotlinxDatetime = "0.4.0"
kotlinxSerializationJson = "1.4.1"
ksp = "1.7.21-1.0.8"
lint = "30.3.1"
okhttp = "4.10.0"
protobuf = "3.21.9"
protobuf = "3.21.12"
protobufPlugin = "0.8.19"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0"
room = "2.5.0-beta02"
room = "2.5.0-rc01"
secrets = "2.0.1"
turbine = "0.12.1"
@ -54,12 +55,13 @@ android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-windowSizeClass = {group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" }
@ -81,24 +83,24 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" }
androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" }
androidx-window-manager = {module = "androidx.window:window", version.ref = "androidxWindowManager"}
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" }
androidx-tracing-ktx = {group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" }
androidx-tracing-ktx = { group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" }
androidx-window-manager = { module = "androidx.window:window", version.ref = "androidxWindowManager" }
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" }
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil"}
coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil"}
coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil"}
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" }
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
@ -107,14 +109,14 @@ kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime",
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lint" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
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" }
@ -124,9 +126,9 @@ kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-pl
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -80,7 +80,7 @@ class DesignSystemDetector : Detector(), Detector.UastScanner {
// instead of hardcoded names.
val METHOD_NAMES = mapOf(
"MaterialTheme" to "NiaTheme",
"Button" to "NiaFilledButton",
"Button" to "NiaButton",
"OutlinedButton" to "NiaOutlinedButton",
"TextButton" to "NiaTextButton",
"FilterChip" to "NiaFilterChip",
@ -92,10 +92,10 @@ class DesignSystemDetector : Detector(), Detector.UastScanner {
"NavigationRailItem" to "NiaNavigationRailItem",
"TabRow" to "NiaTabRow",
"Tab" to "NiaTab",
"IconToggleButton" to "NiaToggleButton",
"FilledIconToggleButton" to "NiaToggleButton",
"FilledTonalIconToggleButton" to "NiaToggleButton",
"OutlinedIconToggleButton" to "NiaToggleButton",
"IconToggleButton" to "NiaIconToggleButton",
"FilledIconToggleButton" to "NiaIconToggleButton",
"FilledTonalIconToggleButton" to "NiaIconToggleButton",
"OutlinedIconToggleButton" to "NiaIconToggleButton",
"CenterAlignedTopAppBar" to "NiaTopAppBar",
"SmallTopAppBar" to "NiaTopAppBar",
"MediumTopAppBar" to "NiaTopAppBar",

Loading…
Cancel
Save