Merge branch 'main' into bw/initialMetrics

pull/145/head
Ben Weiss 2 years ago
commit 6509b6ac9d
No known key found for this signature in database
GPG Key ID: 8424F9C1E763A74C

@ -32,7 +32,7 @@ jobs:
- name: Generate cache key
run: ./scripts/checksum.sh checksum.txt
- uses: actions/cache@v2
- uses: actions/cache@v3
with:
path: |
~/.gradle/caches/modules-*
@ -53,27 +53,27 @@ jobs:
run: ./gradlew testDemoDebug testProdDebug --stacktrace
- name: Upload Demo build outputs (APKs)
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: build-outputs-demo
path: app/demo/build/outputs
- name: Upload Prod build outputs (APKs)
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: build-outputs-prod
path: app/prod/build/outputs
- name: Upload Demo build reports
if: always()
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: build-reports-demo
path: app/demo/build/reports
- name: Upload Prod build reports
if: always()
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: build-reports-prod
path: app/prod/build/reports
@ -101,7 +101,7 @@ jobs:
- name: Generate cache key
run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt
- uses: actions/cache@v2
- uses: actions/cache@v3
with:
path: |
~/.gradle/caches/modules-*
@ -121,7 +121,7 @@ jobs:
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: test-reports
path: '*/build/reports/androidTests'

@ -34,14 +34,7 @@
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" />
<option name="CONTINUATION_INDENT_IN_PARAMETER_LISTS" value="false" />
<option name="CONTINUATION_INDENT_IN_ARGUMENT_LISTS" value="false" />
<option name="CONTINUATION_INDENT_FOR_EXPRESSION_BODIES" value="false" />
<option name="CONTINUATION_INDENT_FOR_CHAINED_CALLS" value="false" />
<option name="CONTINUATION_INDENT_IN_SUPERTYPE_LISTS" value="false" />
<option name="CONTINUATION_INDENT_IN_IF_CONDITIONS" value="false" />
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="1" />
<option name="IF_RPAREN_ON_NEW_LINE" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<Properties>
<option name="KEEP_BLANK_LINES" value="true" />
@ -307,19 +300,11 @@
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="CALL_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
<option name="CALL_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_PARAMETERS_WRAP" value="5" />
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
<option name="EXTENDS_LIST_WRAP" value="1" />
<option name="METHOD_CALL_CHAIN_WRAP" value="1" />
<option name="ASSIGNMENT_WRAP" value="1" />
<option name="FIELD_ANNOTATION_WRAP" value="1" />
<option name="PARAMETER_ANNOTATION_WRAP" value="1" />
<option name="VARIABLE_ANNOTATION_WRAP" value="1" />

@ -50,6 +50,12 @@ The Now in Android app follows the
and is described in detail in the
[architecture learning journey](docs/ArchitectureLearningJourney.md).
# Modularization
The Now in Android app has been fully modularized and you can find the detailed guidance and
description of the modularization strategy used in
[modularization learning journey](docs/ModularizationLearningJourney.md).
# Build
The app contains the usual `debug` and `release` build variants.

@ -37,6 +37,7 @@ android {
dependencies {
implementation(project(":core-ui"))
implementation(project(":core-designsystem"))
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.flowlayout)

@ -37,20 +37,20 @@ 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.ui.component.NiaDropdownMenuButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.component.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.ui.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.ui.component.NiaOutlinedButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTab
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTextButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.ui.component.NiaViewToggleButton
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
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.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
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
/**
* Now in Android component catalog.
@ -416,22 +416,22 @@ fun NiaCatalog() {
FlowRow(mainAxisSpacing = 16.dp) {
var firstChecked by remember { mutableStateOf(false) }
NiaFilterChip(
checked = firstChecked,
onCheckedChange = { checked -> firstChecked = checked },
text = { Text(text = "Enabled".uppercase()) }
selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled".uppercase()) }
)
var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip(
checked = secondChecked,
onCheckedChange = { checked -> secondChecked = checked },
text = { Text(text = "Enabled".uppercase()) }
selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled".uppercase()) }
)
var thirdChecked by remember { mutableStateOf(true) }
NiaFilterChip(
checked = thirdChecked,
onCheckedChange = { checked -> thirdChecked = checked },
selected = thirdChecked,
onSelectedChange = { checked -> thirdChecked = checked },
enabled = false,
text = { Text(text = "Disabled".uppercase()) }
label = { Text(text = "Disabled".uppercase()) }
)
}
}

@ -100,6 +100,7 @@ dependencies {
implementation(project(":feature-topic"))
implementation(project(":core-ui"))
implementation(project(":core-designsystem"))
implementation(project(":core-navigation"))
implementation(project(":sync"))
@ -129,7 +130,7 @@ dependencies {
resolutionStrategy {
force(libs.junit4)
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:3.2")
force("org.objenesis:objenesis:2.6")
}
}
}

@ -16,15 +16,13 @@
package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Grid3x3
import androidx.compose.material.icons.filled.Upcoming
import androidx.compose.material.icons.outlined.Grid3x3
import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon
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.feature.foryou.R.string.for_you
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.feature.interests.R.string.interests
@ -62,22 +60,22 @@ class NiaTopLevelNavigation(private val navController: NavHostController) {
data class TopLevelDestination(
val route: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int
)
val TOP_LEVEL_DESTINATIONS = listOf(
TopLevelDestination(
route = ForYouDestination.route,
selectedIcon = Icons.Filled.Upcoming,
unselectedIcon = Icons.Outlined.Upcoming,
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = for_you
),
TopLevelDestination(
route = InterestsDestination.route,
selectedIcon = Icons.Filled.Grid3x3,
unselectedIcon = Icons.Outlined.Grid3x3,
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interests
)
)

@ -30,13 +30,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
@ -44,16 +41,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
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.NiaNavigationRail
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem
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.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.NiaTopLevelNavigation
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS
@ -78,7 +81,9 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
bottomBar = {
if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {
if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
) {
NiaBottomBar(
onNavigateToTopLevelDestination = niaTopLevelNavigation::navigateTo,
currentDestination = currentDestination
@ -95,7 +100,9 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
)
)
) {
if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact) {
if (windowSizeClass.widthSizeClass != WindowWidthSizeClass.Compact &&
windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact
) {
NiaNavRail(
onNavigateToTopLevelDestination = niaTopLevelNavigation::navigateTo,
currentDestination = currentDestination,
@ -133,18 +140,29 @@ private fun NiaNavRail(
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NavigationRail(modifier = modifier) {
NiaNavigationRail(modifier = modifier) {
TOP_LEVEL_DESTINATIONS.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationRailItem(
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToTopLevelDestination(destination) },
icon = {
Icon(
if (selected) destination.selectedIcon else destination.unselectedIcon,
contentDescription = null
)
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
)
@ -160,30 +178,36 @@ private fun NiaBottomBar(
// Wrap the navigation bar in a surface so the color behind the system
// navigation is equal to the container color of the navigation bar.
Surface(color = MaterialTheme.colorScheme.surface) {
NavigationBar(
NiaNavigationBar(
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
)
),
tonalElevation = 0.dp
)
) {
TOP_LEVEL_DESTINATIONS.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationBarItem(
NiaNavigationBarItem(
selected = selected,
onClick = { onNavigateToTopLevelDestination(destination) },
icon = {
Icon(
if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = null
)
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
)

@ -42,6 +42,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
dependencies {
add("implementation", project(":core-model"))
add("implementation", project(":core-ui"))
add("implementation", project(":core-designsystem"))
add("implementation", project(":core-data"))
add("implementation", project(":core-common"))
add("implementation", project(":core-navigation"))

@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import java.io.File
/**
* Configure Compose-specific options
@ -38,5 +38,33 @@ internal fun Project.configureAndroidCompose(
composeOptions {
kotlinCompilerExtensionVersion = libs.findVersion("androidxCompose").get().toString()
}
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
}
}
}
private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) {
val metricsFolder = File(project.buildDir, "compose-metrics")
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
)
}
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
val enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) {
val reportsFolder = File(project.buildDir, "compose-reports")
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath
)
}
return metricParameters.toList()
}

@ -70,6 +70,6 @@ internal fun Project.configureKotlinAndroid(
}
}
private fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
(this as ExtensionAware).extensions.configure("kotlinOptions", block)
}

@ -20,9 +20,11 @@ import com.google.samples.apps.nowinandroid.core.data.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
@ -48,4 +50,9 @@ interface TestDataModule {
fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository
): NewsRepository
@Binds
fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository
): UserDataRepository
}

@ -21,7 +21,9 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -45,4 +47,9 @@ interface DataModule {
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository
): NewsRepository
@Binds
fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository
): UserDataRepository
}

@ -30,19 +30,4 @@ interface AuthorsRepository : Syncable {
* Gets data for a specific author
*/
fun getAuthorStream(id: String): Flow<Author>
/**
* Sets the user's currently followed authors
*/
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed author
*/
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
/**
* Returns the users currently followed authors
*/
fun getFollowedAuthorIdsStream(): Flow<Set<String>>
}

@ -23,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
@ -38,7 +37,6 @@ import kotlinx.coroutines.flow.map
class OfflineFirstAuthorsRepository @Inject constructor(
private val authorDao: AuthorDao,
private val network: NiaNetworkDataSource,
private val niaPreferences: NiaPreferencesDataSource,
) : AuthorsRepository {
override fun getAuthorStream(id: String): Flow<Author> =
@ -50,14 +48,6 @@ class OfflineFirstAuthorsRepository @Inject constructor(
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) =
niaPreferences.setFollowedAuthorIds(followedAuthorIds)
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
override fun getFollowedAuthorIdsStream(): Flow<Set<String>> = niaPreferences.followedAuthorIds
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
synchronizer.changeListSync(
versionReader = ChangeListVersions::authorVersion,

@ -23,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -38,7 +37,6 @@ import kotlinx.coroutines.flow.map
class OfflineFirstTopicsRepository @Inject constructor(
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
private val niaPreferences: NiaPreferencesDataSource,
) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> =
@ -48,14 +46,6 @@ class OfflineFirstTopicsRepository @Inject constructor(
override fun getTopic(id: String): Flow<Topic> =
topicDao.getTopicEntity(id).map { it.asExternalModel() }
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferences.toggleFollowedTopicId(followedTopicId, followed)
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
synchronizer.changeListSync(
versionReader = ChangeListVersions::topicVersion,

@ -0,0 +1,45 @@
/*
* 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.data.repository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource
) : UserDataRepository {
override val userDataStream: Flow<UserData> =
niaPreferencesDataSource.userDataStream
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) =
niaPreferencesDataSource.setFollowedAuthorIds(followedAuthorIds)
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}

@ -30,19 +30,4 @@ interface TopicsRepository : Syncable {
* Gets data for a specific topic
*/
fun getTopic(id: String): Flow<Topic>
/**
* Sets the user's currently followed topics
*/
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed topic
*/
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean)
/**
* Returns the users currently followed topics
*/
fun getFollowedTopicIdsStream(): Flow<Set<String>>
}

@ -0,0 +1,53 @@
/*
* 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.data.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
interface UserDataRepository {
/**
* Stream of [UserData]
*/
val userDataStream: Flow<UserData>
/**
* Sets the user's currently followed topics
*/
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed topic
*/
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean)
/**
* Sets the user's currently followed authors
*/
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed author
*/
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
@ -41,7 +40,6 @@ import kotlinx.serialization.json.Json
*/
class FakeAuthorsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val niaPreferences: NiaPreferencesDataSource,
private val networkJson: Json,
) : AuthorsRepository {
@ -65,15 +63,5 @@ class FakeAuthorsRepository @Inject constructor(
return getAuthorsStream().map { it.first { author -> author.id == id } }
}
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
niaPreferences.setFollowedAuthorIds(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
}
override fun getFollowedAuthorIdsStream(): Flow<Set<String>> = niaPreferences.followedAuthorIds
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
@ -43,7 +42,6 @@ import kotlinx.serialization.json.Json
class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
private val niaPreferences: NiaPreferencesDataSource
) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = flow<List<Topic>> {
emit(
@ -65,13 +63,5 @@ class FakeTopicsRepository @Inject constructor(
return getTopicsStream().map { it.first { topic -> topic.id == id } }
}
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferences.toggleFollowedTopicId(followedTopicId, followed)
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -0,0 +1,56 @@
/*
* 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.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/**
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository {
override val userDataStream: Flow<UserData> =
niaPreferencesDataSource.userDataStream
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
niaPreferencesDataSource.setFollowedAuthorIds(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}
}

@ -62,7 +62,6 @@ class OfflineFirstAuthorsRepositoryTest {
subject = OfflineFirstAuthorsRepository(
authorDao = authorDao,
network = network,
niaPreferences = niaPreferencesDataSource,
)
}

@ -62,8 +62,7 @@ class OfflineFirstTopicsRepositoryTest {
subject = OfflineFirstTopicsRepository(
topicDao = topicDao,
network = network,
niaPreferences = niaPreferences
network = network
)
}
@ -79,17 +78,6 @@ class OfflineFirstTopicsRepositoryTest {
)
}
@Test
fun offlineFirstTopicsRepository_news_resources_for_interests_is_backed_by_news_resource_dao() =
runTest {
Assert.assertEquals(
niaPreferences.followedTopicIds
.first(),
subject.getFollowedTopicIdsStream()
.first()
)
}
@Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() =
runTest {
@ -183,50 +171,4 @@ class OfflineFirstTopicsRepositoryTest {
synchronizer.getChangeListVersions().topicVersion
)
}
@Test
fun offlineFirstTopicsRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
Assert.assertEquals(
setOf("0"),
subject.getFollowedTopicIdsStream()
.first()
)
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
Assert.assertEquals(
setOf("0", "1"),
subject.getFollowedTopicIdsStream()
.first()
)
Assert.assertEquals(
niaPreferences.followedTopicIds
.first(),
subject.getFollowedTopicIdsStream()
.first()
)
}
@Test
fun offlineFirstTopicsRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
Assert.assertEquals(
setOf("1", "2"),
subject.getFollowedTopicIdsStream()
.first()
)
Assert.assertEquals(
niaPreferences.followedTopicIds
.first(),
subject.getFollowedTopicIdsStream()
.first()
)
}
}

@ -0,0 +1,132 @@
/*
* 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.data.repository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class OfflineFirstUserDataRepositoryTest {
private lateinit var subject: OfflineFirstUserDataRepository
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource
)
}
@Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
assertEquals(
setOf("0"),
subject.userDataStream
.map { it.followedTopics }
.first()
)
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
assertEquals(
setOf("0", "1"),
subject.userDataStream
.map { it.followedTopics }
.first()
)
assertEquals(
niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(),
subject.userDataStream
.map { it.followedTopics }
.first()
)
}
@Test
fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
assertEquals(
setOf("1", "2"),
subject.userDataStream
.map { it.followedTopics }
.first()
)
assertEquals(
niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(),
subject.userDataStream
.map { it.followedTopics }
.first()
)
}
@Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
assertEquals(
setOf("0"),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
assertEquals(
setOf("0", "1"),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
assertEquals(
niaPreferencesDataSource.userDataStream
.map { it.bookmarkedNewsResources }
.first(),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
}
}

@ -57,6 +57,7 @@ protobuf {
dependencies {
implementation(project(":core-common"))
implementation(project(":core-model"))
testImplementation(project(":core-testing"))

@ -18,54 +18,69 @@ package com.google.samples.apps.nowinandroid.core.datastore
import android.util.Log
import androidx.datastore.core.DataStore
import com.google.protobuf.kotlin.DslList
import com.google.protobuf.kotlin.DslProxy
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>
) {
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
this.followedTopicIds.clear()
this.followedTopicIds.addAll(followedTopicIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) {
try {
userPreferences.updateData {
it.copy {
val current =
if (followed) {
followedTopicIds + followedTopicId
} else {
followedTopicIds - followedTopicId
}
this.followedTopicIds.clear()
this.followedTopicIds.addAll(current)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
val userDataStream = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsList.toSet(),
followedTopics = it.followedTopicIdsList.toSet(),
followedAuthors = it.followedAuthorIdsList.toSet(),
)
}
}
val followedTopicIds: Flow<Set<String>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedTopicIdsList.toSet() }
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
userPreferences.setList(
listGetter = { it.followedTopicIds },
listModifier = { followedTopicIds.toList() },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
userPreferences.editList(
add = followed,
value = followedTopicId,
listGetter = { it.followedTopicIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) =
userPreferences.setList(
listGetter = { it.followedAuthorIds },
listModifier = { followedAuthorIds.toList() },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
userPreferences.editList(
add = followed,
value = followedAuthorId,
listGetter = { it.followedAuthorIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
userPreferences.editList(
add = bookmarked,
value = newsResourceId,
listGetter = { it.bookmarkedNewsResourceIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun getChangeListVersions() = userPreferences.data
.map {
@ -105,42 +120,47 @@ class NiaPreferencesDataSource @Inject constructor(
}
}
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(followedAuthorIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
/**
* Adds or removes [value] from the [DslList] provided by [listGetter]
*/
private suspend fun <T : DslProxy> DataStore<UserPreferences>.editList(
add: Boolean,
value: String,
listGetter: (UserPreferencesKt.Dsl) -> DslList<String, T>,
clear: UserPreferencesKt.Dsl.(DslList<String, T>) -> Unit,
addAll: UserPreferencesKt.Dsl.(DslList<String, T>, Iterable<String>) -> Unit
) {
setList(
listGetter = listGetter,
listModifier = { currentList ->
if (add) currentList + value
else currentList - value
},
clear = clear,
addAll = addAll
)
}
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
/**
* Sets the value provided by [listModifier] into the [DslList] read by [listGetter]
*/
private suspend fun <T : DslProxy> DataStore<UserPreferences>.setList(
listGetter: (UserPreferencesKt.Dsl) -> DslList<String, T>,
listModifier: (DslList<String, T>) -> List<String>,
clear: UserPreferencesKt.Dsl.(DslList<String, T>) -> Unit,
addAll: UserPreferencesKt.Dsl.(DslList<String, T>, List<String>) -> Unit
) {
try {
userPreferences.updateData {
updateData {
it.copy {
val current =
if (followed) {
followedAuthorIds + followedAuthorId
} else {
followedAuthorIds - followedAuthorId
}
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(current)
val dslList = listGetter(this)
val newList = listModifier(dslList)
clear(dslList)
addAll(dslList, newList)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
val followedAuthorIds: Flow<Set<String>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedAuthorIdsList.toSet() }
}

@ -30,4 +30,5 @@ message UserPreferences {
bool has_done_int_to_string_id_migration = 8;
repeated string followed_topic_ids = 9;
repeated string followed_author_ids = 10;
repeated string bookmarked_news_resource_ids = 11;
}

@ -0,0 +1 @@
/build

@ -0,0 +1,44 @@
/*
* 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.spotless")
}
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
checkDependencies = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended)
api(libs.androidx.compose.material3)
debugApi(libs.androidx.compose.ui.tooling)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.ui.util)
api(libs.androidx.compose.runtime)
lintPublish(project(":lint"))
androidTestImplementation(project(":core-testing"))
}

@ -0,0 +1,276 @@
/*
* 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
import android.os.Build
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
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.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.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
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
/**
* Tests [NiaTheme] using different combinations of the theme mode parameters:
* darkTheme, dynamicColor, 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
* design system.
*/
class ThemeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun darkThemeFalse_dynamicColorFalse_androidThemeFalse() {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
dynamicColor = false,
androidTheme = false
) {
val colorScheme = LightDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = LightDefaultGradientColors
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeTrue_dynamicColorFalse_androidThemeFalse() {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
dynamicColor = false,
androidTheme = false
) {
val colorScheme = DarkDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeFalse_dynamicColorTrue_androidThemeFalse() {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
dynamicColor = true,
androidTheme = false
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(LocalContext.current)
} else {
LightDefaultColorScheme
}
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GradientColors()
} else {
LightDefaultGradientColors
}
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeTrue_dynamicColorTrue_androidThemeFalse() {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
dynamicColor = true,
androidTheme = false
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
DarkDefaultColorScheme
}
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeFalse_dynamicColorFalse_androidThemeTrue() {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
dynamicColor = false,
androidTheme = true
) {
val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeTrue_dynamicColorFalse_androidThemeTrue() {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
dynamicColor = false,
androidTheme = true
) {
val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeFalse_dynamicColorTrue_androidThemeTrue() {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
dynamicColor = true,
androidTheme = true
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(LocalContext.current)
} else {
LightDefaultColorScheme
}
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GradientColors()
} else {
LightDefaultGradientColors
}
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
@Test
fun darkThemeTrue_dynamicColorTrue_androidThemeTrue() {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
dynamicColor = true,
androidTheme = true
) {
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
DarkDefaultColorScheme
}
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
val gradientColors = GradientColors()
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
}
}
}
/**
* Workaround for the fact that the NiA design system specify all color scheme values.
*/
private fun assertColorSchemesEqual(
expectedColorScheme: ColorScheme,
actualColorScheme: ColorScheme
) {
assertEquals(expectedColorScheme.primary, actualColorScheme.primary)
assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary)
assertEquals(expectedColorScheme.primaryContainer, actualColorScheme.primaryContainer)
assertEquals(expectedColorScheme.onPrimaryContainer, actualColorScheme.onPrimaryContainer)
assertEquals(expectedColorScheme.secondary, actualColorScheme.secondary)
assertEquals(expectedColorScheme.onSecondary, actualColorScheme.onSecondary)
assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer)
assertEquals(
expectedColorScheme.onSecondaryContainer,
actualColorScheme.onSecondaryContainer
)
assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary)
assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary)
assertEquals(expectedColorScheme.tertiaryContainer, actualColorScheme.tertiaryContainer)
assertEquals(expectedColorScheme.onTertiaryContainer, actualColorScheme.onTertiaryContainer)
assertEquals(expectedColorScheme.error, actualColorScheme.error)
assertEquals(expectedColorScheme.onError, actualColorScheme.onError)
assertEquals(expectedColorScheme.errorContainer, actualColorScheme.errorContainer)
assertEquals(expectedColorScheme.onErrorContainer, actualColorScheme.onErrorContainer)
assertEquals(expectedColorScheme.background, actualColorScheme.background)
assertEquals(expectedColorScheme.onBackground, actualColorScheme.onBackground)
assertEquals(expectedColorScheme.surface, actualColorScheme.surface)
assertEquals(expectedColorScheme.onSurface, actualColorScheme.onSurface)
assertEquals(expectedColorScheme.surfaceVariant, actualColorScheme.surfaceVariant)
assertEquals(expectedColorScheme.onSurfaceVariant, actualColorScheme.onSurfaceVariant)
assertEquals(expectedColorScheme.outline, actualColorScheme.outline)
}
}

@ -15,6 +15,6 @@
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.model">
package="com.google.samples.apps.nowinandroid.core.designsystem">
</manifest>

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
@ -34,8 +34,9 @@ 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.ui.theme.LocalBackgroundTheme
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
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
import kotlin.math.tan
/**
@ -75,8 +76,8 @@ fun NiaBackground(
@Composable
fun NiaGradientBackground(
modifier: Modifier = Modifier,
topColor: Color = LocalBackgroundTheme.current.primaryGradientColor,
bottomColor: Color = LocalBackgroundTheme.current.secondaryGradientColor,
topColor: Color = LocalGradientColors.current.primary,
bottomColor: Color = LocalGradientColors.current.secondary,
content: @Composable () -> Unit
) {
val currentTopColor by rememberUpdatedState(topColor)

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
@ -63,9 +63,7 @@ fun NiaFilledButton(
Button(
onClick = onClick,
modifier = if (small) {
Modifier
.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
.then(modifier)
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},
@ -154,9 +152,7 @@ fun NiaOutlinedButton(
OutlinedButton(
onClick = onClick,
modifier = if (small) {
Modifier
.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
.then(modifier)
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},
@ -247,9 +243,7 @@ fun NiaTextButton(
TextButton(
onClick = onClick,
modifier = if (small) {
Modifier
.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
.then(modifier)
modifier.heightIn(min = NiaButtonDefaults.SmallButtonHeight)
} else {
modifier
},

@ -0,0 +1,111 @@
/*
* 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.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
/**
* Now in Android filter chip with included leading checked icon as well as text content slot.
*
* @param selected Whether the chip is currently checked.
* @param onSelectedChange Called when the user clicks the chip and toggles checked.
* @param modifier Modifier to be applied to the chip.
* @param enabled Controls the enabled state of the chip. When `false`, this chip will not be
* clickable and will appear disabled to accessibility services.
* @param label The text label content.
*/
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun NiaFilterChip(
selected: Boolean,
onSelectedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable () -> Unit
) {
FilterChip(
selected = selected,
onClick = { onSelectedChange(!selected) },
label = {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
label()
}
},
modifier = modifier,
enabled = enabled,
selectedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
},
shape = Shapes.Full,
border = FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground,
disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha
),
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) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContainerAlpha
)
} else {
Color.Transparent
},
disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha
),
disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha
),
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onBackground,
selectedLeadingIconColor = MaterialTheme.colorScheme.onBackground
)
)
}
/**
* Now in Android chip default values.
*/
object NiaChipDefaults {
const val DisabledChipContainerAlpha = 0.12f
const val DisabledChipContentAlpha = 0.38f
val ChipBorderWidth = 1.dp
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
@ -26,7 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
/**
* Now in Android dropdown menu button with included trailing icon as well as text label and item

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui
package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.animation.animateColor
@ -46,11 +46,11 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlinx.coroutines.launch
@Composable
fun LoadingWheel(
fun NiaLoadingWheel(
contentDesc: String,
modifier: Modifier = Modifier
) {
@ -134,10 +134,10 @@ fun LoadingWheel(
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Composable
fun LoadingWheelPreview() {
fun NiaLoadingWheelPreview() {
NiaTheme {
Surface {
LoadingWheel(contentDesc = "LoadingWheel")
NiaLoadingWheel(contentDesc = "LoadingWheel")
}
}
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme

@ -14,16 +14,21 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
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
@ -39,9 +44,13 @@ import androidx.compose.ui.unit.dp
* @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 checkedBackgroundRadius The background radius that will be used to draw a background color
* behind the checkedIcon when this toggle button is checked.
* @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(
@ -50,33 +59,38 @@ fun NiaToggleButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: @Composable () -> Unit,
checkedBackgroundRadius: Dp = NiaToggleButtonDefaults.ToggleButtonSize / 2,
checkedIcon: @Composable () -> Unit = icon
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 checkedColor = MaterialTheme.colorScheme.primaryContainer
val checkedRadius = with(LocalDensity.current) {
checkedBackgroundRadius.toPx()
}
val radius = with(LocalDensity.current) { (size / 2).toPx() }
IconButton(
onClick = { onCheckedChange(!checked) },
modifier = Modifier
modifier = modifier
.size(size)
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {})
.drawBehind {
if (checked) drawCircle(
color = checkedColor,
radius = checkedRadius
drawCircle(
color = if (checked) checkedBackgroundColor else backgroundColor,
radius = radius
)
}
.then(modifier),
},
enabled = enabled,
content = {
Box(
modifier = Modifier.sizeIn(
maxWidth = NiaToggleButtonDefaults.ToggleButtonIconSize,
maxHeight = NiaToggleButtonDefaults.ToggleButtonIconSize
maxWidth = iconSize,
maxHeight = iconSize
)
) {
if (checked) checkedIcon() else icon()
val contentColor = if (checked) checkedIconColor else iconColor
CompositionLocalProvider(LocalContentColor provides contentColor) {
if (checked) checkedIcon() else icon()
}
}
}
)

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.google.samples.apps.nowinandroid.core.ui.R
@Composable
fun NiaTopAppBar(
@ -75,7 +74,7 @@ fun NiaTopAppBar(
@Composable
fun NiaTopAppBarPreview() {
NiaTopAppBar(
titleRes = R.string.top_app_bar_preview_title,
titleRes = android.R.string.untitled,
navigationIcon = Icons.Default.Search,
navigationIconContentDescription = "Navigation icon",
actionIcon = Icons.Default.MoreVert,

@ -14,12 +14,12 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.component
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
/**
* Now in Android view toggle button with included trailing icon as well as compact and expanded

@ -14,8 +14,9 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.icon
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.MoreVert
import androidx.compose.material.icons.outlined.AccountCircle
@ -27,6 +28,8 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.Fullscreen
import androidx.compose.material.icons.rounded.Grid3x3
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.ShortText
@ -35,7 +38,7 @@ import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.material.icons.rounded.VolumeOff
import androidx.compose.material.icons.rounded.VolumeUp
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.core.ui.R
import com.google.samples.apps.nowinandroid.core.designsystem.R
/**
* Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs.
@ -54,9 +57,11 @@ object NiaIcons {
val Close = Icons.Rounded.Close
val ExpandLess = Icons.Rounded.ExpandLess
val Fullscreen = Icons.Rounded.Fullscreen
val Grid3x3 = Icons.Rounded.Grid3x3
val MenuBook = R.drawable.ic_menu_book
val MenuBookBorder = R.drawable.ic_menu_book_border
val MoreVert = Icons.Default.MoreVert
val Person = Icons.Rounded.Person
val PlayArrow = Icons.Rounded.PlayArrow
val Search = Icons.Rounded.Search
val ShortText = Icons.Rounded.ShortText
@ -67,3 +72,11 @@ object NiaIcons {
val VolumeOff = Icons.Rounded.VolumeOff
val VolumeUp = Icons.Rounded.VolumeUp
}
/**
* A sealed class to make dealing with [ImageVector] and [DrawableRes] icons easier.
*/
sealed class Icon {
data class ImageVectorIcon(val imageVector: ImageVector) : Icon()
data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon()
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.theme
package com.google.samples.apps.nowinandroid.core.designsystem.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
@ -22,17 +22,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
/**
* A class to model background values for Now in Android,
* including color, tonal elevation and gradient colors.
* A class to model background color and tonal elevation values for Now in Android.
*/
@Immutable
data class BackgroundTheme(
val color: Color = Color.Unspecified,
val tonalElevation: Dp = Dp.Unspecified,
val primaryGradientColor: Color = Color.Unspecified,
val secondaryGradientColor: Color = Color.Unspecified,
val tertiaryGradientColor: Color = Color.Unspecified,
val neutralGradientColor: Color = Color.Unspecified
val tonalElevation: Dp = Dp.Unspecified
)
/**

@ -0,0 +1,86 @@
/*
* 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.theme
import androidx.compose.ui.graphics.Color
/**
* Now in Android colors.
*/
internal val Blue10 = Color(0xFF001F29)
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 DarkGreen10 = Color(0xFF0D1F12)
internal val DarkGreen20 = Color(0xFF223526)
internal val DarkGreen30 = Color(0xFF394B3C)
internal val DarkGreen40 = Color(0xFF4F6352)
internal val DarkGreen80 = Color(0xFFB7CCB8)
internal val DarkGreen90 = Color(0xFFD3E8D3)
internal val DarkGreenGray10 = Color(0xFF1A1C1A)
internal val DarkGreenGray90 = Color(0xFFE2E3DE)
internal val DarkGreenGray95 = Color(0xFFF0F1EC)
internal val DarkGreenGray99 = Color(0xFFFBFDF7)
internal val DarkPurpleGray10 = Color(0xFF201A1B)
internal val DarkPurpleGray90 = Color(0xFFECDFE0)
internal val DarkPurpleGray95 = Color(0xFFFAEEEF)
internal val DarkPurpleGray99 = Color(0xFFFCFCFC)
internal val Green10 = Color(0xFF00210B)
internal val Green20 = Color(0xFF003919)
internal val Green30 = Color(0xFF005227)
internal val Green40 = Color(0xFF006D36)
internal val Green80 = Color(0xFF0EE37C)
internal val Green90 = Color(0xFF5AFF9D)
internal val GreenGray30 = Color(0xFF414941)
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 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 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 PurpleGray50 = Color(0xFF7F747C)
internal val PurpleGray60 = Color(0xFF998D96)
internal val PurpleGray80 = Color(0xFFD0C2CC)
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 Teal10 = Color(0xFF001F26)
internal val Teal20 = Color(0xFF02363F)
internal val Teal30 = Color(0xFF214D56)
internal val Teal40 = Color(0xFF3A656F)
internal val Teal80 = Color(0xFFA2CED9)
internal val Teal90 = Color(0xFFBEEAF6)

@ -0,0 +1,37 @@
/*
* 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.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* A class to model gradient color values for Now in Android.
*/
@Immutable
data class GradientColors(
val primary: Color = Color.Unspecified,
val secondary: Color = Color.Unspecified,
val tertiary: Color = Color.Unspecified,
val neutral: Color = Color.Unspecified
)
/**
* A composition local for [GradientColors].
*/
val LocalGradientColors = staticCompositionLocalOf { GradientColors() }

@ -14,9 +14,10 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.theme
package com.google.samples.apps.nowinandroid.core.designsystem.theme
import android.os.Build
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@ -32,7 +33,8 @@ import androidx.compose.ui.unit.dp
/**
* Light default theme color scheme
*/
private val LightDefaultColorScheme = lightColorScheme(
@VisibleForTesting
val LightDefaultColorScheme = lightColorScheme(
primary = Purple40,
onPrimary = Color.White,
primaryContainer = Purple90,
@ -61,7 +63,8 @@ private val LightDefaultColorScheme = lightColorScheme(
/**
* Dark default theme color scheme
*/
private val DarkDefaultColorScheme = darkColorScheme(
@VisibleForTesting
val DarkDefaultColorScheme = darkColorScheme(
primary = Purple80,
onPrimary = Purple20,
primaryContainer = Purple30,
@ -90,7 +93,8 @@ private val DarkDefaultColorScheme = darkColorScheme(
/**
* Light Android theme color scheme
*/
private val LightAndroidColorScheme = lightColorScheme(
@VisibleForTesting
val LightAndroidColorScheme = lightColorScheme(
primary = Green40,
onPrimary = Color.White,
primaryContainer = Green90,
@ -119,7 +123,8 @@ private val LightAndroidColorScheme = lightColorScheme(
/**
* Dark Android theme color scheme
*/
private val DarkAndroidColorScheme = darkColorScheme(
@VisibleForTesting
val DarkAndroidColorScheme = darkColorScheme(
primary = Green80,
onPrimary = Green20,
primaryContainer = Green30,
@ -145,6 +150,26 @@ private val DarkAndroidColorScheme = darkColorScheme(
outline = GreenGray60
)
/**
* Light default gradient colors
*/
val LightDefaultGradientColors = GradientColors(
primary = Purple95,
secondary = Orange95,
tertiary = Blue95,
neutral = DarkPurpleGray95
)
/**
* Light Android background theme
*/
val LightAndroidBackgroundTheme = BackgroundTheme(color = DarkGreenGray95)
/**
* Dark Android background theme
*/
val DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black)
/**
* Now in Android theme.
*
@ -164,45 +189,45 @@ fun NiaTheme(
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dynamicColor -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} else {
if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
}
androidTheme && darkTheme -> DarkAndroidColorScheme
androidTheme -> LightAndroidColorScheme
darkTheme -> DarkDefaultColorScheme
else -> LightDefaultColorScheme
androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
val defaultGradientColors = GradientColors()
val gradientColors = when {
dynamicColor -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
defaultGradientColors
} else {
if (darkTheme) defaultGradientColors else LightDefaultGradientColors
}
}
androidTheme -> defaultGradientColors
else -> if (darkTheme) defaultGradientColors else LightDefaultGradientColors
}
val defaultBackgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
val backgroundTheme = when {
androidTheme && darkTheme -> BackgroundTheme(
color = Color.Black
)
androidTheme -> BackgroundTheme(
color = DarkGreenGray95
)
darkTheme -> BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
)
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp,
primaryGradientColor = colorScheme.primary.lighten(0.95f),
secondaryGradientColor = colorScheme.secondary.lighten(0.95f),
tertiaryGradientColor = colorScheme.tertiary.lighten(0.95f),
neutralGradientColor = colorScheme.surface.lighten(0.95f)
)
else -> BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp,
primaryGradientColor = Purple95,
secondaryGradientColor = Orange95,
tertiaryGradientColor = Blue95,
neutralGradientColor = DarkPurpleGray95
)
dynamicColor -> defaultBackgroundTheme
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme
}
CompositionLocalProvider(LocalBackgroundTheme provides backgroundTheme) {
CompositionLocalProvider(
LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme
) {
MaterialTheme(
colorScheme = colorScheme,
typography = NiaTypography,

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui.theme
package com.google.samples.apps.nowinandroid.core.designsystem.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
@ -27,7 +27,7 @@ import androidx.compose.ui.unit.sp
*
* TODO: Add custom font
*/
val NiaTypography = Typography(
internal val NiaTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.W400,
fontSize = 57.sp,

@ -16,21 +16,10 @@
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
id("kotlinx-serialization")
alias(libs.plugins.ksp)
id("kotlin")
id("nowinandroid.spotless")
}
dependencies {
testImplementation(project(":core-testing"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}

@ -0,0 +1,26 @@
/*
* 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.model.data
/**
* Class summarizing user interest data
*/
data class UserData(
val bookmarkedNewsResources: Set<String>,
val followedTopics: Set<String>,
val followedAuthors: Set<String>,
)

@ -25,12 +25,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class TestAuthorsRepository : AuthorsRepository {
/**
* The backing hot flow for the list of followed author ids for testing.
*/
private val _followedAuthorIds: MutableSharedFlow<Set<String>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* The backing hot flow for the list of author ids for testing.
*/
@ -43,21 +37,6 @@ class TestAuthorsRepository : AuthorsRepository {
return authorsFlow.map { authors -> authors.find { it.id == id }!! }
}
override fun getFollowedAuthorIdsStream(): Flow<Set<String>> = _followedAuthorIds
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
_followedAuthorIds.tryEmit(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
getCurrentFollowedAuthors()?.let { current ->
_followedAuthorIds.tryEmit(
if (followed) current.plus(followedAuthorId)
else current.minus(followedAuthorId)
)
}
}
override suspend fun syncWith(synchronizer: Synchronizer) = true
/**
@ -66,9 +45,4 @@ class TestAuthorsRepository : AuthorsRepository {
fun sendAuthors(authors: List<Author>) {
authorsFlow.tryEmit(authors)
}
/**
* A test-only API to allow querying the current followed authors.
*/
fun getCurrentFollowedAuthors(): Set<String>? = _followedAuthorIds.replayCache.firstOrNull()
}

@ -25,12 +25,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class TestTopicsRepository : TopicsRepository {
/**
* The backing hot flow for the list of followed topic ids for testing.
*/
private val _followedTopicIds: MutableSharedFlow<Set<String>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* The backing hot flow for the list of topics ids for testing.
*/
@ -43,21 +37,6 @@ class TestTopicsRepository : TopicsRepository {
return topicsFlow.map { topics -> topics.find { it.id == id }!! }
}
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) {
_followedTopicIds.tryEmit(followedTopicIds)
}
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) {
getCurrentFollowedTopics()?.let { current ->
_followedTopicIds.tryEmit(
if (followed) current.plus(followedTopicId)
else current.minus(followedTopicId)
)
}
}
override fun getFollowedTopicIdsStream(): Flow<Set<String>> = _followedTopicIds
/**
* A test-only API to allow controlling the list of topics from tests.
*/
@ -65,10 +44,5 @@ class TestTopicsRepository : TopicsRepository {
topicsFlow.tryEmit(topics)
}
/**
* A test-only API to allow querying the current followed topics.
*/
fun getCurrentFollowedTopics(): Set<String>? = _followedTopicIds.replayCache.firstOrNull()
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -0,0 +1,88 @@
/*
* 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.testing.repository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterNotNull
private val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
followedAuthors = emptySet()
)
class TestUserDataRepository : UserDataRepository {
/**
* The backing hot flow for the list of followed topic ids for testing.
*/
private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)
private val currentUserData get() = _userData.replayCache.firstOrNull() ?: emptyUserData
override val userDataStream: Flow<UserData> = _userData.filterNotNull()
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) {
_userData.tryEmit(currentUserData.copy(followedTopics = followedTopicIds))
}
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) {
currentUserData.let { current ->
val followedTopics = if (followed) current.followedTopics + followedTopicId
else current.followedTopics - followedTopicId
_userData.tryEmit(current.copy(followedTopics = followedTopics))
}
}
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
_userData.tryEmit(currentUserData.copy(followedAuthors = followedAuthorIds))
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
currentUserData.let { current ->
val followedAuthors = if (followed) current.followedAuthors + followedAuthorId
else current.followedAuthors - followedAuthorId
_userData.tryEmit(current.copy(followedAuthors = followedAuthors))
}
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
currentUserData.let { current ->
val bookmarkedNews = if (bookmarked) current.bookmarkedNewsResources + newsResourceId
else current.bookmarkedNewsResources - newsResourceId
_userData.tryEmit(current.copy(bookmarkedNewsResources = bookmarkedNews))
}
}
/**
* A test-only API to allow querying the current followed topics.
*/
fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics
/**
* A test-only API to allow querying the current followed authors.
*/
fun getCurrentFollowedAuthors(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedAuthors
}

@ -16,31 +16,27 @@
package com.google.samples.apps.nowinandroid.core.testing.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A [TestRule] that initializes the main dispatcher to [dispatcher], which defaults to a
* [StandardTestDispatcher].
* A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher]
* for the duration of the test.
*/
class TestDispatcherRule(
private val dispatcher: CoroutineDispatcher = StandardTestDispatcher()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
Dispatchers.setMain(dispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

@ -21,6 +21,7 @@ plugins {
}
dependencies {
implementation(project(":core-designsystem"))
implementation(project(":core-model"))
implementation(libs.androidx.core.ktx)

@ -1,106 +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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun FollowButton(
following: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onFollowChange: ((Boolean) -> Unit)? = null,
backgroundColor: Color = Color.Transparent,
size: Dp = 32.dp,
iconSize: Dp = size / 2,
followingContentDescription: String? = null,
notFollowingContentDescription: String? = null,
) {
val background = if (following) {
MaterialTheme.colorScheme.secondaryContainer
} else {
backgroundColor
}
Box(
modifier = modifier.followButton(onFollowChange, following, enabled, background, size),
contentAlignment = Alignment.Center
) {
if (following) {
Icon(
imageVector = Filled.Done,
contentDescription = followingContentDescription,
modifier = Modifier.size(iconSize)
)
} else {
Icon(
imageVector = Filled.Add,
contentDescription = notFollowingContentDescription,
modifier = Modifier.size(iconSize)
)
}
}
}
private fun Modifier.followButton(
onFollowChange: ((Boolean) -> Unit)?,
following: Boolean,
enabled: Boolean,
background: Color,
size: Dp
): Modifier = composed {
val boxModifier = if (onFollowChange != null) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = 24.dp)
this
.toggleable(
value = following,
onValueChange = onFollowChange,
enabled = enabled,
role = Role.Checkbox,
interactionSource = interactionSource,
indication = ripple
)
} else {
this
}
boxModifier
.clip(CircleShape)
.background(background)
.size(size)
}

@ -28,15 +28,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -61,11 +56,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import coil.compose.AsyncImage
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.model.data.Author
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.Topic
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.datetime.Instant
@ -184,7 +181,7 @@ fun NewsResourceAuthors(
modifier = authorImageModifier
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp),
imageVector = Icons.Filled.Person,
imageVector = NiaIcons.Person,
contentDescription = null // decorative image
)
}
@ -208,23 +205,23 @@ fun BookmarkButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val clickActionLabel = stringResource(
if (isBookmarked) R.string.unbookmark else R.string.bookmark
)
IconToggleButton(
NiaToggleButton(
checked = isBookmarked,
onCheckedChange = { onClick() },
modifier = modifier.semantics {
// Use custom label for accessibility services to communicate button's action to user.
// Pass null for action to only override the label and not the actual action.
this.onClick(label = clickActionLabel, action = null)
modifier = modifier,
icon = {
Icon(
painter = painterResource(NiaIcons.BookmarkBorder),
contentDescription = stringResource(R.string.bookmark)
)
},
checkedIcon = {
Icon(
painter = painterResource(NiaIcons.Bookmark),
contentDescription = stringResource(R.string.unbookmark)
)
}
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Filled.BookmarkBorder,
contentDescription = null // handled by click label of parent
)
}
)
}
@Composable

@ -1,90 +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.ui.component
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
/**
* Now in Android filter chip with included leading checked icon as well as text content slot.
*
* @param checked Whether the chip is currently checked.
* @param onCheckedChange Called when the user clicks the chip and toggles checked.
* @param modifier Modifier to be applied to the chip.
* @param enabled Controls the enabled state of the chip. When `false`, this chip will not be
* clickable and will appear disabled to accessibility services.
* @param text The text label content.
*/
@Composable
fun NiaFilterChip(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: @Composable () -> Unit
) {
// TODO: Replace with Chip when available in Compose Material 3: b/197399111
NiaOutlinedButton(
onClick = { onCheckedChange(!checked) },
modifier = Modifier
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {})
.then(modifier),
enabled = enabled,
small = true,
border = NiaButtonDefaults.outlinedButtonBorder(
enabled = enabled,
disabledColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = if (checked) {
NiaButtonDefaults.DisabledButtonContentAlpha
} else {
NiaButtonDefaults.DisabledButtonContainerAlpha
}
)
),
colors = NiaButtonDefaults.outlinedButtonColors(
containerColor = if (checked) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Transparent
},
disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaButtonDefaults.DisabledButtonContainerAlpha
)
} else {
Color.Transparent
}
),
text = text,
leadingIcon = if (checked) {
{
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
}
} else {
null
}
)
}

@ -1,108 +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.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.ColorUtils
import kotlin.math.roundToInt
/**
* Now in Android colors.
*/
val Blue10 = Color(0xFF001F29)
val Blue20 = Color(0xFF003544)
val Blue30 = Color(0xFF004D61)
val Blue40 = Color(0xFF006781)
val Blue80 = Color(0xFF5DD4FB)
val Blue90 = Color(0xFFB5EAFF)
val Blue95 = Color(0xFFDCF5FF)
val DarkGreen10 = Color(0xFF0D1F12)
val DarkGreen20 = Color(0xFF223526)
val DarkGreen30 = Color(0xFF394B3C)
val DarkGreen40 = Color(0xFF4F6352)
val DarkGreen80 = Color(0xFFB7CCB8)
val DarkGreen90 = Color(0xFFD3E8D3)
val DarkGreenGray10 = Color(0xFF1A1C1A)
val DarkGreenGray90 = Color(0xFFE2E3DE)
val DarkGreenGray95 = Color(0xFFF0F1EC)
val DarkGreenGray99 = Color(0xFFFBFDF7)
val DarkPurpleGray10 = Color(0xFF201A1B)
val DarkPurpleGray90 = Color(0xFFECDFE0)
val DarkPurpleGray95 = Color(0xFFFAEEEF)
val DarkPurpleGray99 = Color(0xFFFCFCFC)
val Green10 = Color(0xFF00210B)
val Green20 = Color(0xFF003919)
val Green30 = Color(0xFF005227)
val Green40 = Color(0xFF006D36)
val Green80 = Color(0xFF0EE37C)
val Green90 = Color(0xFF5AFF9D)
val GreenGray30 = Color(0xFF414941)
val GreenGray50 = Color(0xFF727971)
val GreenGray60 = Color(0xFF8B938A)
val GreenGray80 = Color(0xFFC1C9BF)
val GreenGray90 = Color(0xFFDDE5DB)
val Orange10 = Color(0xFF390C00)
val Orange20 = Color(0xFF5D1900)
val Orange30 = Color(0xFF812800)
val Orange40 = Color(0xFFA23F16)
val Orange80 = Color(0xFFFFB599)
val Orange90 = Color(0xFFFFDBCE)
val Orange95 = Color(0xFFFFEDE6)
val Purple10 = Color(0xFF36003D)
val Purple20 = Color(0xFF560A5E)
val Purple30 = Color(0xFF702776)
val Purple40 = Color(0xFF8C4190)
val Purple80 = Color(0xFFFFA8FF)
val Purple90 = Color(0xFFFFD5FC)
val Purple95 = Color(0xFFFFEBFB)
val PurpleGray30 = Color(0xFF4E444C)
val PurpleGray50 = Color(0xFF7F747C)
val PurpleGray60 = Color(0xFF998D96)
val PurpleGray80 = Color(0xFFD0C2CC)
val PurpleGray90 = Color(0xFFEDDEE8)
val Red10 = Color(0xFF410001)
val Red20 = Color(0xFF680003)
val Red30 = Color(0xFF930006)
val Red40 = Color(0xFFBA1B1B)
val Red80 = Color(0xFFFFB4A9)
val Red90 = Color(0xFFFFDAD4)
val Teal10 = Color(0xFF001F26)
val Teal20 = Color(0xFF02363F)
val Teal30 = Color(0xFF214D56)
val Teal40 = Color(0xFF3A656F)
val Teal80 = Color(0xFFA2CED9)
val Teal90 = Color(0xFFBEEAF6)
/**
* Lighten the current [Color] instance to the given [luminance].
*
* This is needed because we can't access the token values directly. For the dynamic color theme,
* this makes it impossible to get the 95% luminance token of the different theme colors.
* TODO: Link to bug
*/
internal fun Color.lighten(luminance: Float): Color {
val hsl = FloatArray(3)
ColorUtils.RGBToHSL(
(red * 256).roundToInt(),
(green * 256).roundToInt(),
(blue * 256).roundToInt(),
hsl
)
hsl[2] = luminance
val color = Color(ColorUtils.HSLToColor(hsl))
return color
}

@ -20,6 +20,4 @@
<string name="back">Back</string>
<string name="card_tap_action">Open Resource Link</string>
<string name="top_app_bar_preview_title">Title</string>
</resources>

@ -22,13 +22,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
@ -50,15 +49,15 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@Composable
fun AuthorRoute(
@ -91,18 +90,12 @@ internal fun AuthorScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (authorState) {
AuthorUiState.Loading -> {
item {
LoadingWheel(
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = R.string.author_loading),
)
@ -126,13 +119,7 @@ internal fun AuthorScreen(
}
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -186,7 +173,7 @@ private fun LazyListScope.authorCards(news: NewsUiState) {
)
}
is NewsUiState.Loading -> item {
LoadingWheel(contentDesc = "Loading news") // TODO
NiaLoadingWheel(contentDesc = "Loading news") // TODO
}
else -> item {
Text("Error") // TODO
@ -217,8 +204,8 @@ private fun AuthorToolbar(
val selected = uiState.isFollowed
NiaFilterChip(
modifier = Modifier.padding(horizontal = 16.dp),
checked = selected,
onCheckedChange = onFollowClick,
selected = selected,
onSelectedChange = onFollowClick,
) {
if (selected) {
Text(stringResource(id = R.string.author_following))

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -33,13 +34,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AuthorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authorsRepository: AuthorsRepository,
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
newsRepository: NewsRepository
) : ViewModel() {
@ -49,7 +52,9 @@ class AuthorViewModel @Inject constructor(
// Observe the followed authors, as they could change over time.
private val followedAuthorIdsStream: Flow<Result<Set<String>>> =
authorsRepository.getFollowedAuthorIdsStream().asResult()
userDataRepository.userDataStream
.map { it.followedAuthors }
.asResult()
// Observe author information
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream(
@ -102,7 +107,7 @@ class AuthorViewModel @Inject constructor(
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
authorsRepository.toggleFollowedAuthorId(authorId, followed)
userDataRepository.toggleFollowedAuthorId(authorId, followed)
}
}
}

@ -17,16 +17,19 @@
package com.google.samples.apps.nowinandroid.feature.author
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
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.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
@ -35,11 +38,16 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class AuthorViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: AuthorViewModel
@ -52,6 +60,7 @@ class AuthorViewModelTest {
AuthorDestination.authorIdArg to testInputAuthors[0].author.id
)
),
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
newsRepository = newsRepository
)
@ -59,90 +68,91 @@ class AuthorViewModelTest {
@Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
viewModel.uiState.test {
awaitItem()
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val successAuthorUiState = item.authorState as AuthorUiState.Success
val authorFromRepository = authorsRepository.getAuthorStream(
id = testInputAuthors[0].author.id
).first()
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
}
val successAuthorUiState = item.authorState as AuthorUiState.Success
val authorFromRepository = authorsRepository.getAuthorStream(
id = testInputAuthors[0].author.id
).first()
successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
}
@Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
}
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
viewModel.followAuthorToggle(true)
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
awaitItem().authorState
)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
viewModel.followAuthorToggle(true)
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
viewModel.uiState.value.authorState
)
collectJob.cancel()
}
}

@ -31,8 +31,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -54,9 +52,11 @@ import androidx.compose.ui.text.style.TextAlign
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.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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.FollowButton
import com.google.samples.apps.nowinandroid.core.ui.JankMetricEffect
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@ -138,7 +138,7 @@ fun AuthorItem(
modifier = authorImageModifier
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp),
imageVector = Icons.Filled.Person,
imageVector = NiaIcons.Person,
contentDescription = null // decorative image
)
} else {
@ -149,12 +149,24 @@ fun AuthorItem(
contentDescription = null
)
}
FollowButton(
following = following,
backgroundColor = MaterialTheme.colorScheme.surface,
size = 20.dp,
iconSize = 14.dp,
modifier = Modifier.align(Alignment.BottomEnd)
NiaToggleButton(
checked = following,
onCheckedChange = onAuthorClick,
modifier = Modifier.align(Alignment.BottomEnd),
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = null
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
},
size = 24.dp,
backgroundColor = MaterialTheme.colorScheme.surface
)
}
Spacer(modifier = Modifier.height(4.dp))
@ -172,7 +184,7 @@ fun AuthorItem(
@Preview
@Composable
fun AuthorCarouselPreview() {
MaterialTheme {
NiaTheme {
Surface {
AuthorsCarousel(
authors = listOf(
@ -219,7 +231,7 @@ fun AuthorCarouselPreview() {
@Preview
@Composable
fun AuthorItemPreview() {
MaterialTheme {
NiaTheme {
Surface {
AuthorItem(
author = Author(

@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
@ -49,9 +50,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -82,6 +80,13 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
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.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
@ -89,15 +94,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.JankMetricEffect
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.ui.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTypography
import kotlin.math.floor
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@ -139,11 +136,11 @@ fun ForYouScreen(
topBar = {
NiaTopAppBar(
titleRes = R.string.top_app_bar_title,
navigationIcon = Icons.Filled.Search,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
),
actionIcon = Icons.Outlined.AccountCircle,
actionIcon = NiaIcons.AccountCircle,
actionIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
),
@ -210,13 +207,7 @@ fun ForYouScreen(
)
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -243,7 +234,7 @@ private fun LazyListScope.InterestsSelection(
ForYouInterestsSelectionUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
LoadingWheel(
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
@ -261,7 +252,7 @@ private fun LazyListScope.InterestsSelection(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
style = NiaTypography.titleMedium
style = MaterialTheme.typography.titleMedium
)
}
item {
@ -271,7 +262,7 @@ private fun LazyListScope.InterestsSelection(
.fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp),
textAlign = TextAlign.Center,
style = NiaTypography.bodyMedium
style = MaterialTheme.typography.bodyMedium
)
}
item {
@ -396,7 +387,7 @@ private fun SingleTopicButton(
)
Text(
text = name,
style = NiaTypography.titleSmall,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(horizontal = 12.dp)
.weight(1f),
@ -407,14 +398,14 @@ private fun SingleTopicButton(
onCheckedChange = { checked -> onClick(topicId, checked) },
icon = {
Icon(
imageVector = NiaIcons.Add, contentDescription = name,
tint = MaterialTheme.colorScheme.onSurface
imageVector = NiaIcons.Add,
contentDescription = name
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check, contentDescription = name,
tint = MaterialTheme.colorScheme.onSurface
imageVector = NiaIcons.Check,
contentDescription = name
)
}
)
@ -456,7 +447,7 @@ private fun LazyListScope.Feed(
ForYouFeedUiState.Loading -> {
if (showLoadingUIIfLoading) {
item {
LoadingWheel(
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),

@ -27,6 +27,7 @@ import androidx.lifecycle.viewmodel.compose.saveable
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
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.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -51,26 +52,25 @@ import kotlinx.coroutines.launch
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val topicsRepository: TopicsRepository,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository,
private val userDataRepository: UserDataRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val followedInterestsState: StateFlow<FollowedInterestsState> =
combine(
authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getFollowedTopicIdsStream(),
) { followedAuthors, followedTopics ->
if (followedAuthors.isEmpty() && followedTopics.isEmpty()) {
None
} else {
FollowedInterests(
authorIds = followedAuthors,
topicIds = followedTopics
)
userDataRepository.userDataStream
.map { userData ->
if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) {
None
} else {
FollowedInterests(
authorIds = userData.followedAuthors,
topicIds = userData.followedTopics
)
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
@ -232,8 +232,8 @@ class ForYouViewModel @Inject constructor(
}
viewModelScope.launch {
topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
authorsRepository.setFollowedAuthorIds(inProgressAuthorSelection)
userDataRepository.setFollowedTopicIds(inProgressTopicSelection)
userDataRepository.setFollowedAuthorIds(inProgressAuthorSelection)
// Clear out the old selection, in case we return to onboarding
withMutableSnapshot {
inProgressTopicSelection = emptySet()

@ -24,8 +24,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -38,8 +36,9 @@ 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.ui.FollowButton
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
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.feature.interests.R.string
@Composable
@ -69,15 +68,25 @@ fun InterestsItem(
Spacer(modifier = Modifier.width(16.dp))
InterestContent(name, description)
}
FollowButton(
following = following,
onFollowChange = onFollowButtonClick,
notFollowingContentDescription = stringResource(
id = string.interests_card_follow_button_content_desc
),
followingContentDescription = stringResource(
id = string.interests_card_unfollow_button_content_desc
)
NiaToggleButton(
checked = following,
onCheckedChange = onFollowButtonClick,
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = stringResource(
id = string.interests_card_follow_button_content_desc
)
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = stringResource(
id = string.interests_card_unfollow_button_content_desc
)
)
}
)
}
}
@ -108,7 +117,7 @@ private fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier)
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp),
imageVector = Icons.Filled.Person,
imageVector = NiaIcons.Person,
contentDescription = null, // decorative image
)
} else {

@ -19,35 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
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.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTab
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@Composable
fun InterestsRoute(
@ -94,28 +90,25 @@ fun InterestsScreen(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
NiaTopAppBar(
titleRes = R.string.interests,
navigationIcon = Icons.Filled.Search,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
),
actionIcon = Icons.Filled.MoreVert,
actionIcon = NiaIcons.MoreVert,
actionIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
)
when (uiState) {
InterestsUiState.Loading ->
LoadingWheel(
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = R.string.interests_loading),
)
@ -161,7 +154,6 @@ private fun InterestsContent(
topics = uiState.topics,
onTopicClick = navigateToTopic,
onFollowButtonClick = followTopic,
modifier = Modifier.padding(top = 8.dp)
)
}
1 -> {
@ -169,7 +161,6 @@ private fun InterestsContent(
authors = uiState.authors,
onAuthorClick = navigateToAuthor,
onFollowButtonClick = followAuthor,
modifier = Modifier.padding(top = 8.dp)
)
}
}

@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
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.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import dagger.hilt.android.lifecycle.HiltViewModel
@ -35,8 +36,9 @@ import kotlinx.coroutines.launch
@HiltViewModel
class InterestsViewModel @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val topicsRepository: TopicsRepository
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository
) : ViewModel() {
private val _tabState = MutableStateFlow(
@ -48,18 +50,17 @@ class InterestsViewModel @Inject constructor(
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine(
userDataRepository.userDataStream,
authorsRepository.getAuthorsStream(),
authorsRepository.getFollowedAuthorIdsStream(),
topicsRepository.getTopicsStream(),
topicsRepository.getFollowedTopicIdsStream(),
) { availableAuthors, followedAuthorIdsState, availableTopics, followedTopicIdsState ->
) { userData, availableAuthors, availableTopics ->
InterestsUiState.Interests(
authors = availableAuthors
.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in followedAuthorIdsState
isFollowed = author.id in userData.followedAuthors
)
}
.sortedBy { it.author.name },
@ -67,7 +68,7 @@ class InterestsViewModel @Inject constructor(
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedTopicIdsState
isFollowed = topic.id in userData.followedTopics
)
}
.sortedBy { it.topic.name }
@ -81,13 +82,13 @@ class InterestsViewModel @Inject constructor(
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
topicsRepository.toggleFollowedTopicId(followedTopicId, followed)
userDataRepository.toggleFollowedTopicId(followedTopicId, followed)
}
}
fun followAuthor(followedAuthorId: String, followed: Boolean) {
viewModelScope.launch {
authorsRepository.toggleFollowedAuthorId(followedAuthorId, followed)
userDataRepository.toggleFollowedAuthorId(followedAuthorId, followed)
}
}

@ -16,13 +16,12 @@
package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@ -40,7 +39,8 @@ fun TopicsTabContent(
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp)
modifier = modifier.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 8.dp)
) {
topics.forEach { followableTopic ->
item {
@ -56,13 +56,7 @@ fun TopicsTabContent(
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -75,7 +69,8 @@ fun AuthorsTabContent(
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp)
modifier = modifier.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 8.dp)
) {
authors.forEach { followableAuthor ->
item {
@ -91,13 +86,7 @@ fun AuthorsTabContent(
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}

@ -16,167 +16,180 @@
package com.google.samples.apps.nowinandroid.interests
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class InterestsViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private lateinit var viewModel: InterestsViewModel
@Before
fun setup() {
viewModel = InterestsViewModel(authorsRepository, topicsRepository)
viewModel = InterestsViewModel(
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
topicsRepository = topicsRepository,
)
}
@Test
fun uiState_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
}
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
}
@Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(setOf("1"))
topicsRepository.setFollowedTopicIds(emptySet())
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.setFollowedTopicIds(setOf("1"))
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet())
userDataRepository.setFollowedTopicIds(setOf("1"))
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
assertEquals(
false,
(awaitItem() as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = toggleTopicId,
true
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
awaitItem()
)
}
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
assertEquals(
false,
(viewModel.uiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = toggleTopicId,
true
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
viewModel.uiState.value
)
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
topicsRepository.sendTopics(listOf())
topicsRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testInputAuthors[1].author.id,
followed = true
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
awaitItem()
)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
followedAuthorId = testInputAuthors[1].author.id,
followed = true
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
viewModel.uiState.value
)
collectJob.cancel()
}
@Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
)
assertEquals(
true,
(awaitItem() as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = toggleTopicId,
false
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
awaitItem()
)
}
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
)
assertEquals(
true,
(viewModel.uiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = toggleTopicId,
false
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
viewModel.uiState.value
)
collectJob.cancel()
}
@Test
fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
)
topicsRepository.sendTopics(listOf())
topicsRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testOutputAuthors[1].author.id,
followed = false
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
awaitItem()
)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
)
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
followedAuthorId = testOutputAuthors[1].author.id,
followed = false
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
viewModel.uiState.value
)
collectJob.cancel()
}
}

@ -23,13 +23,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons.Filled
@ -48,14 +47,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@ -91,17 +90,11 @@ internal fun TopicScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicState) {
Loading -> item {
LoadingWheel(
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
@ -124,13 +117,7 @@ internal fun TopicScreen(
}
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -157,7 +144,8 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.align(Alignment.CenterHorizontally)
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(216.dp)
.padding(bottom = 12.dp)
)
@ -184,7 +172,7 @@ private fun LazyListScope.TopicCards(news: NewsUiState) {
)
}
is NewsUiState.Loading -> item {
LoadingWheel(contentDesc = "Loading news") // TODO
NiaLoadingWheel(contentDesc = "Loading news") // TODO
}
else -> item {
Text("Error") // TODO
@ -192,6 +180,19 @@ private fun LazyListScope.TopicCards(news: NewsUiState) {
}
}
@Preview
@Composable
private fun TopicBodyPreview() {
NiaTheme {
LazyColumn {
TopicBody(
"Jetpack Compose", "Lorem ipsum maximum",
NewsUiState.Success(emptyList()), ""
)
}
}
}
@Composable
private fun TopicToolbar(
uiState: FollowableTopic,
@ -214,8 +215,8 @@ private fun TopicToolbar(
}
val selected = uiState.isFollowed
NiaFilterChip(
checked = selected,
onCheckedChange = onFollowClick,
selected = selected,
onSelectedChange = onFollowClick,
modifier = Modifier.padding(end = 24.dp)
) {
if (selected) {

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -33,13 +34,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val topicsRepository: TopicsRepository,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
newsRepository: NewsRepository
) : ViewModel() {
@ -47,7 +50,9 @@ class TopicViewModel @Inject constructor(
// Observe the followed topics, as they could change over time.
private val followedTopicIdsStream: Flow<Result<Set<String>>> =
topicsRepository.getFollowedTopicIdsStream().asResult()
userDataRepository.userDataStream
.map { it.followedTopics }
.asResult()
// Observe topic information
private val topic: Flow<Result<Topic>> = topicsRepository.getTopic(topicId).asResult()
@ -97,7 +102,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
topicsRepository.toggleFollowedTopicId(topicId, followed)
userDataRepository.toggleFollowedTopicId(topicId, followed)
}
}
}

@ -17,16 +17,19 @@
package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.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.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
@ -35,11 +38,16 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class TopicViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: TopicViewModel
@ -49,6 +57,7 @@ class TopicViewModelTest {
viewModel = TopicViewModel(
savedStateHandle =
SavedStateHandle(mapOf(TopicDestination.topicIdArg to testInputTopics[0].topic.id)),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
newsRepository = newsRepository
)
@ -56,87 +65,88 @@ class TopicViewModelTest {
@Test
fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val successTopicState = item.topicState as TopicUiState.Success
val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id
).first()
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
assertEquals(topicFromRepository, successTopicState.followableTopic.topic)
}
val successTopicState = item.topicState as TopicUiState.Success
val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id
).first()
assertEquals(topicFromRepository, successTopicState.followableTopic.topic)
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
}
@Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(TopicUiState.Loading, awaitItem().topicState)
}
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, awaitItem().topicState)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0.
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
viewModel.followTopicToggle(true)
assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]),
awaitItem().topicState
)
}
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0.
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
viewModel.followTopicToggle(true)
assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]),
viewModel.uiState.value.topicState
)
collectJob.cancel()
}
}

@ -4,18 +4,18 @@ androidDesugarJdkLibs = "1.1.5"
androidGradlePlugin = "7.2.1"
androidxActivity = "1.4.0"
androidxAppCompat = "1.4.2"
androidxCompose = "1.2.0-beta03"
androidxCompose = "1.2.0-rc02"
androidxComposeMaterial3 = "1.0.0-alpha13"
androidxCore = "1.8.0"
androidxCustomView = "1.0.0-beta02"
androidxCustomView = "1.0.0-rc01"
androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0"
androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.5.0-rc01"
androidxMacroBenchmark = "1.1.0-rc03"
androidxLifecycle = "2.5.0-rc02"
androidxMacroBenchmark = "1.1.0"
androidxNavigation = "2.4.2"
androidxMetrics = "1.0.0-alpha01"
androidxProfileinstaller = "1.2.0-beta03"
androidxProfileinstaller = "1.2.0-rc01"
androidxSavedState = "1.1.0"
androidxStartup = "1.1.1"
androidxWindowManager = "1.0.0"
@ -30,20 +30,21 @@ hiltExt = "1.0.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
kotlin = "1.6.21"
kotlinxCoroutines = "1.6.2"
kotlinxCoroutines = "1.6.3"
kotlinxDatetime = "0.3.3"
kotlinxSerializationJson = "1.3.3"
ksp = "1.6.21-1.0.5"
ktlint = "0.43.0"
lint = "30.2.1"
material3 = "1.6.1"
okhttp = "4.9.3"
okhttp = "4.10.0"
protobuf = "3.21.1"
protobufPlugin = "0.8.18"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0"
room = "2.4.2"
secrets = "2.0.1"
spotless = "6.7.0"
spotless = "6.7.2"
turbine = "0.8.0"
[libraries]
@ -98,10 +99,12 @@ hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-p
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-serializationPlugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" }
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" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
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" }
material3 = { group = "com.google.android.material", name = "material", version.ref = "material3" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }

1
lint/.gitignore vendored

@ -0,0 +1 @@
/build

@ -0,0 +1,30 @@
/*
* 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.
*/
plugins {
id 'java-library'
id 'kotlin'
id 'com.android.lint'
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
compileOnly libs.kotlin.stdlib
compileOnly libs.lint.api
}

@ -0,0 +1,120 @@
/*
* 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.lint.designsystem
import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UQualifiedReferenceExpression
/**
* A detector that checks for incorrect usages of Compose Material APIs over equivalents in
* the Now in Android design system module.
*/
@Suppress("UnstableApiUsage")
class DesignSystemDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>> {
return listOf(
UCallExpression::class.java,
UQualifiedReferenceExpression::class.java
)
}
override fun createUastHandler(context: JavaContext): UElementHandler {
return object : UElementHandler() {
override fun visitCallExpression(node: UCallExpression) {
val name = node.methodName ?: return
val preferredName = METHOD_NAMES[name] ?: return
reportIssue(context, node, name, preferredName)
}
override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression) {
val name = node.receiver.asRenderString()
val preferredName = RECEIVER_NAMES[name] ?: return
reportIssue(context, node, name, preferredName)
}
}
}
companion object {
@JvmField
val ISSUE: Issue = Issue.create(
id = "DesignSystem",
briefDescription = "Design system",
explanation = "This check highlights calls in code that use Compose Material " +
"composables instead of equivalents from the Now in Android design system " +
"module.",
category = Category.CUSTOM_LINT_CHECKS,
priority = 7,
severity = Severity.ERROR,
implementation = Implementation(
DesignSystemDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
)
// Unfortunately :lint is a Java module and thus can't depend on the :core-designsystem
// Android module, so we can't use composable function references (eg. ::Button.name)
// instead of hardcoded names.
val METHOD_NAMES = mapOf(
"MaterialTheme" to "NiaTheme",
"Button" to "NiaFilledButton",
"OutlinedButton" to "NiaOutlinedButton",
"TextButton" to "NiaTextButton",
"FilterChip" to "NiaFilterChip",
"ElevatedFilterChip" to "NiaFilterChip",
"DropdownMenu" to "NiaDropdownMenu",
"NavigationBar" to "NiaNavigationBar",
"NavigationBarItem" to "NiaNavigationBarItem",
"NavigationRail" to "NiaNavigationRail",
"NavigationRailItem" to "NiaNavigationRailItem",
"TabRow" to "NiaTabRow",
"Tab" to "NiaTab",
"IconToggleButton" to "NiaToggleButton",
"FilledIconToggleButton" to "NiaToggleButton",
"FilledTonalIconToggleButton" to "NiaToggleButton",
"OutlinedIconToggleButton" to "NiaToggleButton",
"CenterAlignedTopAppBar" to "NiaTopAppBar",
"SmallTopAppBar" to "NiaTopAppBar",
"MediumTopAppBar" to "NiaTopAppBar",
"LargeTopAppBar" to "NiaTopAppBar"
)
val RECEIVER_NAMES = mapOf(
"Icons" to "NiaIcons"
)
fun reportIssue(
context: JavaContext,
node: UElement,
name: String,
preferredName: String
) {
context.report(
ISSUE, node, context.getLocation(node),
"Using $name instead of $preferredName"
)
}
}
}

@ -0,0 +1,40 @@
/*
* 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.lint.designsystem
import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
/**
* An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in
* the Now in Android design system module.
*/
@Suppress("UnstableApiUsage")
class DesignSystemIssueRegistry : IssueRegistry() {
override val issues = listOf(DesignSystemDetector.ISSUE)
override val api: Int = CURRENT_API
override val minApi: Int = 12
override val vendor: Vendor = Vendor(
vendorName = "Now in Android",
feedbackUrl = "https://github.com/android/nowinandroid/issues",
contact = "https://github.com/android/nowinandroid"
)
}

@ -0,0 +1,17 @@
#
# Copyright 2022 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemIssueRegistry

@ -49,6 +49,7 @@ include(":core-data-test")
include(":core-database")
include(":core-datastore")
include(":core-datastore-test")
include(":core-designsystem")
include(":core-model")
include(":core-navigation")
include(":core-network")
@ -58,4 +59,5 @@ include(":feature-author")
include(":feature-foryou")
include(":feature-interests")
include(":feature-topic")
include(":lint")
include(":sync")

Loading…
Cancel
Save