diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml
index e61626a2d..fbe30b745 100644
--- a/.github/workflows/Build.yaml
+++ b/.github/workflows/Build.yaml
@@ -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'
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index a24d14540..0a9e39c9d 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -34,14 +34,7 @@
-
-
-
-
-
-
-
-
+
@@ -307,19 +300,11 @@
+
-
-
-
-
-
-
-
-
-
diff --git a/README.md b/README.md
index 9712a6e8e..1f8af3122 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts
index 28edda383..26eb06969 100644
--- a/app-nia-catalog/build.gradle.kts
+++ b/app-nia-catalog/build.gradle.kts
@@ -37,6 +37,7 @@ android {
dependencies {
implementation(project(":core-ui"))
+ implementation(project(":core-designsystem"))
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.flowlayout)
diff --git a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt
index 30e73e17b..d4d7b499b 100644
--- a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt
+++ b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt
@@ -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()) }
)
}
}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 70e6b25c3..816911b04 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
}
}
}
diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt
index e76918eae..54d7bd402 100644
--- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt
+++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt
@@ -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
)
)
diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
index bedd20704..c0f5c1b20 100644
--- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
+++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
@@ -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)) }
)
diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
index 9545bfb12..58ccca210 100644
--- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
@@ -42,6 +42,7 @@ class AndroidFeatureConventionPlugin : Plugin {
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"))
diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
index b59ec5625..cb5eb8bd3 100644
--- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
+++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
@@ -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 {
+ val metricParameters = mutableListOf()
+ 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()
}
diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt
index 36f7e522d..ef9ec9072 100644
--- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt
+++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt
@@ -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)
}
diff --git a/core-data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core-data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt
index 3966ead2e..fa31a37a5 100644
--- a/core-data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt
+++ b/core-data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt
@@ -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
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt
index f44ff986e..3e30e7e59 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt
@@ -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
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt
index 6921b94b3..8a27d5704 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt
@@ -30,19 +30,4 @@ interface AuthorsRepository : Syncable {
* Gets data for a specific author
*/
fun getAuthorStream(id: String): Flow
-
- /**
- * Sets the user's currently followed authors
- */
- suspend fun setFollowedAuthorIds(followedAuthorIds: Set)
-
- /**
- * Toggles the user's newly followed/unfollowed author
- */
- suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
-
- /**
- * Returns the users currently followed authors
- */
- fun getFollowedAuthorIdsStream(): Flow>
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt
index 5344810f3..0773bdc59 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt
@@ -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 =
@@ -50,14 +48,6 @@ class OfflineFirstAuthorsRepository @Inject constructor(
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }
- override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) =
- niaPreferences.setFollowedAuthorIds(followedAuthorIds)
-
- override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
- niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
-
- override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds
-
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
synchronizer.changeListSync(
versionReader = ChangeListVersions::authorVersion,
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt
index 4e2c64c9d..04d800021 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepository.kt
@@ -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> =
@@ -48,14 +46,6 @@ class OfflineFirstTopicsRepository @Inject constructor(
override fun getTopic(id: String): Flow =
topicDao.getTopicEntity(id).map { it.asExternalModel() }
- override suspend fun setFollowedTopicIds(followedTopicIds: Set) =
- 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,
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt
new file mode 100644
index 000000000..046189037
--- /dev/null
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt
@@ -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 =
+ niaPreferencesDataSource.userDataStream
+
+ override suspend fun setFollowedTopicIds(followedTopicIds: Set) =
+ niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
+
+ override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
+ niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
+
+ override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) =
+ 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)
+}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/TopicsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/TopicsRepository.kt
index a4d2185b1..2e48fb334 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/TopicsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/TopicsRepository.kt
@@ -30,19 +30,4 @@ interface TopicsRepository : Syncable {
* Gets data for a specific topic
*/
fun getTopic(id: String): Flow
-
- /**
- * Sets the user's currently followed topics
- */
- suspend fun setFollowedTopicIds(followedTopicIds: Set)
-
- /**
- * Toggles the user's newly followed/unfollowed topic
- */
- suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean)
-
- /**
- * Returns the users currently followed topics
- */
- fun getFollowedTopicIdsStream(): Flow>
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt
new file mode 100644
index 000000000..deea011cc
--- /dev/null
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt
@@ -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
+
+ /**
+ * Sets the user's currently followed topics
+ */
+ suspend fun setFollowedTopicIds(followedTopicIds: Set)
+
+ /**
+ * 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)
+
+ /**
+ * 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)
+}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt
index 4a0ebf0ba..a6e2f69d8 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt
@@ -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) {
- niaPreferences.setFollowedAuthorIds(followedAuthorIds)
- }
-
- override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
- niaPreferences.toggleFollowedAuthorId(followedAuthorId, followed)
- }
-
- override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds
-
override suspend fun syncWith(synchronizer: Synchronizer) = true
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt
index ec18600f0..7ee46fed6 100644
--- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeTopicsRepository.kt
@@ -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> = flow> {
emit(
@@ -65,13 +63,5 @@ class FakeTopicsRepository @Inject constructor(
return getTopicsStream().map { it.first { topic -> topic.id == id } }
}
- override suspend fun setFollowedTopicIds(followedTopicIds: Set) =
- 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
}
diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt
new file mode 100644
index 000000000..5feb5eab3
--- /dev/null
+++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt
@@ -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 =
+ niaPreferencesDataSource.userDataStream
+
+ override suspend fun setFollowedTopicIds(followedTopicIds: Set) =
+ niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
+
+ override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
+ niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
+
+ override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) {
+ 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)
+ }
+}
diff --git a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepositoryTest.kt b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepositoryTest.kt
index 1d7bc0407..61c5fb809 100644
--- a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepositoryTest.kt
+++ b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepositoryTest.kt
@@ -62,7 +62,6 @@ class OfflineFirstAuthorsRepositoryTest {
subject = OfflineFirstAuthorsRepository(
authorDao = authorDao,
network = network,
- niaPreferences = niaPreferencesDataSource,
)
}
diff --git a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt
index 9481af62f..48570d5f0 100644
--- a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt
+++ b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt
@@ -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()
- )
- }
}
diff --git a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt
new file mode 100644
index 000000000..d65820ea0
--- /dev/null
+++ b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt
@@ -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()
+ )
+ }
+}
diff --git a/core-datastore/build.gradle.kts b/core-datastore/build.gradle.kts
index 52d7ebc7b..033005aa8 100644
--- a/core-datastore/build.gradle.kts
+++ b/core-datastore/build.gradle.kts
@@ -57,6 +57,7 @@ protobuf {
dependencies {
implementation(project(":core-common"))
+ implementation(project(":core-model"))
testImplementation(project(":core-testing"))
diff --git a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt
index 938e85af6..6d7028f0c 100644
--- a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt
+++ b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt
@@ -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
) {
- suspend fun setFollowedTopicIds(followedTopicIds: Set) {
- 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> = userPreferences.data
- .retry {
- Log.e("NiaPreferences", "Failed to read user preferences", it)
- true
- }
- .map { it.followedTopicIdsList.toSet() }
+ suspend fun setFollowedTopicIds(followedTopicIds: Set) =
+ 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) =
+ 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) {
- 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 DataStore.editList(
+ add: Boolean,
+ value: String,
+ listGetter: (UserPreferencesKt.Dsl) -> DslList,
+ clear: UserPreferencesKt.Dsl.(DslList) -> Unit,
+ addAll: UserPreferencesKt.Dsl.(DslList, Iterable) -> 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 DataStore.setList(
+ listGetter: (UserPreferencesKt.Dsl) -> DslList,
+ listModifier: (DslList) -> List,
+ clear: UserPreferencesKt.Dsl.(DslList) -> Unit,
+ addAll: UserPreferencesKt.Dsl.(DslList, List) -> 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> = userPreferences.data
- .retry {
- Log.e("NiaPreferences", "Failed to read user preferences", it)
- true
- }
- .map { it.followedAuthorIdsList.toSet() }
}
diff --git a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto
index 466565e9e..407031215 100644
--- a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto
+++ b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto
@@ -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;
}
diff --git a/core-designsystem/.gitignore b/core-designsystem/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core-designsystem/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core-designsystem/build.gradle.kts b/core-designsystem/build.gradle.kts
new file mode 100644
index 000000000..bada9e329
--- /dev/null
+++ b/core-designsystem/build.gradle.kts
@@ -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"))
+}
\ No newline at end of file
diff --git a/core-designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt b/core-designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt
new file mode 100644
index 000000000..d0fb5ff83
--- /dev/null
+++ b/core-designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt
@@ -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)
+ }
+}
diff --git a/core-model/src/main/AndroidManifest.xml b/core-designsystem/src/main/AndroidManifest.xml
similarity index 90%
rename from core-model/src/main/AndroidManifest.xml
rename to core-designsystem/src/main/AndroidManifest.xml
index e04125b63..0f6a479a3 100644
--- a/core-model/src/main/AndroidManifest.xml
+++ b/core-designsystem/src/main/AndroidManifest.xml
@@ -15,6 +15,6 @@
limitations under the License.
-->
+ package="com.google.samples.apps.nowinandroid.core.designsystem">
\ No newline at end of file
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Background.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt
similarity index 93%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Background.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt
index cc797409f..a3af38f23 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Background.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt
@@ -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)
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Button.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt
similarity index 97%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Button.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt
index 0e6fc5dde..696c7e8fc 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Button.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt
@@ -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
},
diff --git a/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt
new file mode 100644
index 000000000..3d2cf3f2c
--- /dev/null
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt
@@ -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
+}
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/DropdownMenu.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/DropdownMenu.kt
similarity index 97%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/DropdownMenu.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/DropdownMenu.kt
index 0e21fdc19..270fd07a6 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/DropdownMenu.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/DropdownMenu.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/LoadingWheel.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt
similarity index 95%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/LoadingWheel.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt
index fb0b31be6..4ce7e1473 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/LoadingWheel.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/LoadingWheel.kt
@@ -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")
}
}
}
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Navigation.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt
similarity index 98%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Navigation.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt
index 28f906932..8537f5fbf 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Navigation.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Navigation.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tabs.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt
similarity index 97%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tabs.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt
index 72bfb19ca..76a0a3248 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tabs.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tabs.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tag.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt
similarity index 97%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tag.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt
index a47a85667..c2790c538 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Tag.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ToggleButton.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ToggleButton.kt
similarity index 60%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ToggleButton.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ToggleButton.kt
index df2b67f73..b50bc9ff9 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ToggleButton.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ToggleButton.kt
@@ -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()
+ }
}
}
)
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/TopAppBar.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt
similarity index 94%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/TopAppBar.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt
index d0a1e6309..09cb209db 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/TopAppBar.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/TopAppBar.kt
@@ -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,
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ViewToggle.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ViewToggle.kt
similarity index 93%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ViewToggle.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ViewToggle.kt
index e9779ad70..a8267040a 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/ViewToggle.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/ViewToggle.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/icon/NiaIcons.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt
similarity index 82%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/icon/NiaIcons.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt
index c89407059..b6c06c8ee 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/icon/NiaIcons.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt
@@ -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()
+}
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Background.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Background.kt
similarity index 69%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Background.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Background.kt
index d39e21e67..f91e5a30d 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Background.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Background.kt
@@ -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
)
/**
diff --git a/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt
new file mode 100644
index 000000000..50c6f972f
--- /dev/null
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt
@@ -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)
diff --git a/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Gradient.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Gradient.kt
new file mode 100644
index 000000000..7ecaa94b8
--- /dev/null
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Gradient.kt
@@ -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() }
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Theme.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt
similarity index 69%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Theme.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt
index 8b7f7a151..efec108f8 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Theme.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt
@@ -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,
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Type.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt
similarity index 96%
rename from core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Type.kt
rename to core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt
index d65a75add..9d8fb8df8 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Type.kt
+++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Type.kt
@@ -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,
diff --git a/core-ui/src/main/res/drawable/ic_bookmark.xml b/core-designsystem/src/main/res/drawable/ic_bookmark.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_bookmark.xml
rename to core-designsystem/src/main/res/drawable/ic_bookmark.xml
diff --git a/core-ui/src/main/res/drawable/ic_bookmark_border.xml b/core-designsystem/src/main/res/drawable/ic_bookmark_border.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_bookmark_border.xml
rename to core-designsystem/src/main/res/drawable/ic_bookmark_border.xml
diff --git a/core-ui/src/main/res/drawable/ic_bookmarks.xml b/core-designsystem/src/main/res/drawable/ic_bookmarks.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_bookmarks.xml
rename to core-designsystem/src/main/res/drawable/ic_bookmarks.xml
diff --git a/core-ui/src/main/res/drawable/ic_bookmarks_border.xml b/core-designsystem/src/main/res/drawable/ic_bookmarks_border.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_bookmarks_border.xml
rename to core-designsystem/src/main/res/drawable/ic_bookmarks_border.xml
diff --git a/core-ui/src/main/res/drawable/ic_menu_book.xml b/core-designsystem/src/main/res/drawable/ic_menu_book.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_menu_book.xml
rename to core-designsystem/src/main/res/drawable/ic_menu_book.xml
diff --git a/core-ui/src/main/res/drawable/ic_menu_book_border.xml b/core-designsystem/src/main/res/drawable/ic_menu_book_border.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_menu_book_border.xml
rename to core-designsystem/src/main/res/drawable/ic_menu_book_border.xml
diff --git a/core-ui/src/main/res/drawable/ic_placeholder_default.xml b/core-designsystem/src/main/res/drawable/ic_placeholder_default.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_placeholder_default.xml
rename to core-designsystem/src/main/res/drawable/ic_placeholder_default.xml
diff --git a/core-ui/src/main/res/drawable/ic_upcoming.xml b/core-designsystem/src/main/res/drawable/ic_upcoming.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_upcoming.xml
rename to core-designsystem/src/main/res/drawable/ic_upcoming.xml
diff --git a/core-ui/src/main/res/drawable/ic_upcoming_border.xml b/core-designsystem/src/main/res/drawable/ic_upcoming_border.xml
similarity index 100%
rename from core-ui/src/main/res/drawable/ic_upcoming_border.xml
rename to core-designsystem/src/main/res/drawable/ic_upcoming_border.xml
diff --git a/core-model/build.gradle.kts b/core-model/build.gradle.kts
index a413b36c4..34a905c6c 100644
--- a/core-model/build.gradle.kts
+++ b/core-model/build.gradle.kts
@@ -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)
}
\ No newline at end of file
diff --git a/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt
new file mode 100644
index 000000000..4535a14b4
--- /dev/null
+++ b/core-model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt
@@ -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,
+ val followedTopics: Set,
+ val followedAuthors: Set,
+)
diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt
index c3f69d921..13d01e480 100644
--- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt
+++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt
@@ -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> =
- 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> = _followedAuthorIds
-
- override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) {
- _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) {
authorsFlow.tryEmit(authors)
}
-
- /**
- * A test-only API to allow querying the current followed authors.
- */
- fun getCurrentFollowedAuthors(): Set? = _followedAuthorIds.replayCache.firstOrNull()
}
diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt
index 262c9687e..3aa064a2d 100644
--- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt
+++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt
@@ -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> =
- 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) {
- _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> = _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? = _followedTopicIds.replayCache.firstOrNull()
-
override suspend fun syncWith(synchronizer: Synchronizer) = true
}
diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt
new file mode 100644
index 000000000..4b0dad48d
--- /dev/null
+++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt
@@ -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(replay = 1, onBufferOverflow = DROP_OLDEST)
+
+ private val currentUserData get() = _userData.replayCache.firstOrNull() ?: emptyUserData
+
+ override val userDataStream: Flow = _userData.filterNotNull()
+
+ override suspend fun setFollowedTopicIds(followedTopicIds: Set) {
+ _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) {
+ _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? =
+ _userData.replayCache.firstOrNull()?.followedTopics
+
+ /**
+ * A test-only API to allow querying the current followed authors.
+ */
+ fun getCurrentFollowedAuthors(): Set? =
+ _userData.replayCache.firstOrNull()?.followedAuthors
+}
diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestDispatcherRule.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt
similarity index 54%
rename from core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestDispatcherRule.kt
rename to core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt
index 789779716..5b8807b75 100644
--- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestDispatcherRule.kt
+++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt
@@ -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()
+ }
}
diff --git a/core-ui/build.gradle.kts b/core-ui/build.gradle.kts
index 3d47cb9ee..1d9624f25 100644
--- a/core-ui/build.gradle.kts
+++ b/core-ui/build.gradle.kts
@@ -21,6 +21,7 @@ plugins {
}
dependencies {
+ implementation(project(":core-designsystem"))
implementation(project(":core-model"))
implementation(libs.androidx.core.ktx)
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt
deleted file mode 100644
index 46a162942..000000000
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowButton.kt
+++ /dev/null
@@ -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)
-}
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
index 718840d17..3aabc7105 100644
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
+++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
@@ -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
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Chip.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Chip.kt
deleted file mode 100644
index 95a28892a..000000000
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/component/Chip.kt
+++ /dev/null
@@ -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
- }
- )
-}
diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Color.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Color.kt
deleted file mode 100644
index d8d07dce0..000000000
--- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/theme/Color.kt
+++ /dev/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
-}
diff --git a/core-ui/src/main/res/values/strings.xml b/core-ui/src/main/res/values/strings.xml
index a55cc697a..548ea63ef 100644
--- a/core-ui/src/main/res/values/strings.xml
+++ b/core-ui/src/main/res/values/strings.xml
@@ -20,6 +20,4 @@
Back
Open Resource Link
-
- Title
diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt
index 74c72624d..22c8e8c09 100644
--- a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt
+++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt
@@ -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))
diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt
index 61329570c..3401c0529 100644
--- a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt
+++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt
@@ -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>> =
- authorsRepository.getFollowedAuthorIdsStream().asResult()
+ userDataRepository.userDataStream
+ .map { it.followedAuthors }
+ .asResult()
// Observe author information
private val author: Flow> = authorsRepository.getAuthorStream(
@@ -102,7 +107,7 @@ class AuthorViewModel @Inject constructor(
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
- authorsRepository.toggleFollowedAuthorId(authorId, followed)
+ userDataRepository.toggleFollowedAuthorId(authorId, followed)
}
}
}
diff --git a/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt
index 01f70c04f..0e1fc48d2 100644
--- a/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt
+++ b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt
@@ -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()
}
}
diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt
index 1544d7dad..687cdbbd8 100644
--- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt
+++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt
@@ -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(
diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
index f52e887bb..0a997d894 100644
--- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
+++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
@@ -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(),
diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
index f7e36c430..8f7a424f2 100644
--- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
+++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
@@ -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 =
- 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()
diff --git a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
index 89ec4f8c0..ca4ef7094 100644
--- a/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
+++ b/feature-foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
@@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou
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.FollowableTopic
@@ -28,8 +27,11 @@ 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.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
-import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
-import kotlinx.coroutines.flow.combine
+import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
+import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
@@ -38,10 +40,15 @@ 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 ForYouViewModelTest {
@get:Rule
- val dispatcherRule = TestDispatcherRule()
+ val mainDispatcherRule = MainDispatcherRule()
+ private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
@@ -50,6 +57,7 @@ class ForYouViewModelTest {
@Before
fun setup() {
viewModel = ForYouViewModel(
+ userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
topicsRepository = topicsRepository,
newsRepository = newsRepository,
@@ -57,1449 +65,1308 @@ class ForYouViewModelTest {
)
}
- /**
- * A pairing of [ForYouInterestsSelectionUiState] and [ForYouFeedUiState] for ease of testing
- * state updates as a single flow.
- */
- private data class ForYouUiState(
- val interestsSelectionState: ForYouInterestsSelectionUiState,
- val feedState: ForYouFeedUiState,
- )
-
- private val ForYouViewModel.uiState
- get() =
- combine(
- interestsSelectionState,
- feedState,
- ::ForYouUiState
- )
-
@Test
fun stateIsInitiallyLoading() = runTest {
- viewModel.uiState.test {
- assertEquals(
- ForYouUiState(
- ForYouInterestsSelectionUiState.Loading,
- ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
- }
+ assertEquals(
+ ForYouInterestsSelectionUiState.Loading,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
}
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
- viewModel.uiState.test {
- assertEquals(
- ForYouUiState(
- ForYouInterestsSelectionUiState.Loading,
- ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
- topicsRepository.sendTopics(sampleTopics)
- }
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.Loading,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
- viewModel.uiState.test {
- assertEquals(
- ForYouUiState(
- ForYouInterestsSelectionUiState.Loading,
- ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
- authorsRepository.sendAuthors(sampleAuthors)
- }
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ authorsRepository.sendAuthors(sampleAuthors)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.Loading,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest {
- viewModel.uiState.test {
- assertEquals(
- ForYouUiState(
- ForYouInterestsSelectionUiState.Loading,
- ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
- topicsRepository.setFollowedTopicIds(emptySet())
- }
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ userDataRepository.setFollowedTopicIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.Loading,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Success(emptyList()), viewModel.feedState.value)
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsLoadingWhenAuthorsAreLoading() = runTest {
- viewModel.uiState.test {
- assertEquals(
- ForYouUiState(
- ForYouInterestsSelectionUiState.Loading,
- ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
- authorsRepository.setFollowedAuthorIds(emptySet())
- }
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ userDataRepository.setFollowedAuthorIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.Loading,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Success(emptyList()), viewModel.feedState.value)
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest {
- viewModel.uiState.test {
- advanceUntilIdle()
- expectMostRecentItem()
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
+ isFollowed = false
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
)
),
- expectMostRecentItem()
- )
- }
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- authorsRepository.sendAuthors(sampleAuthors)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
- )
+ isFollowed = false
),
- expectMostRecentItem()
- )
- }
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest {
- viewModel.uiState
- .test {
- advanceUntilIdle()
- expectMostRecentItem()
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(setOf("0", "1"))
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
- newsRepository.sendNewsResources(sampleNewsResources)
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = sampleNewsResources.map {
- SaveableNewsResource(
- newsResource = it,
- isSaved = false
- )
- }
- )
- ),
- awaitItem()
- )
- }
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(setOf("0", "1"))
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(ForYouFeedUiState.Loading, viewModel.feedState.value)
+
+ newsRepository.sendNewsResources(sampleNewsResources)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed =
+ sampleNewsResources.map {
+ SaveableNewsResource(
+ newsResource = it,
+ isSaved = false
+ )
+ }
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedAuthors() = runTest {
- viewModel.uiState
- .test {
- advanceUntilIdle()
- expectMostRecentItem()
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(setOf("0", "1"))
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Loading
- ),
- awaitItem()
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
- newsRepository.sendNewsResources(sampleNewsResources)
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = sampleNewsResources.map {
- SaveableNewsResource(
- newsResource = it,
- isSaved = false
- )
- }
- )
- ),
- awaitItem()
- )
- }
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(setOf("0", "1"))
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Loading,
+ viewModel.feedState.value
+ )
+
+ newsRepository.sendNewsResources(sampleNewsResources)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = sampleNewsResources.map {
+ SaveableNewsResource(
+ newsResource = it,
+ isSaved = false
+ )
+ }
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- viewModel.updateTopicSelection("1", isChecked = true)
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = true
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList(),
- )
+ isFollowed = false
),
- awaitItem()
- )
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = true
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Loading
+ isFollowed = false
),
- awaitItem()
- )
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = true
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
),
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[1],
- isSaved = false
- ),
- SaveableNewsResource(
- newsResource = sampleNewsResources[2],
- isSaved = false
- )
- )
- )
+ isFollowed = false
),
- awaitItem()
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList(),
+ ),
+ viewModel.feedState.value
+ )
+
+ viewModel.updateTopicSelection("1", isChecked = true)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = true
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[1],
+ isSaved = false
+ ),
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[2],
+ isSaved = false
+ )
)
- }
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSelectingAuthor() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- viewModel.updateAuthorSelection("1", isChecked = true)
-
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = true
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList(),
- )
+ isFollowed = false
),
- awaitItem()
- )
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = true
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Loading
+ isFollowed = false
),
- awaitItem()
- )
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = true
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
),
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[1],
- isSaved = false
- ),
- SaveableNewsResource(
- newsResource = sampleNewsResources[2],
- isSaved = false
- )
- )
- )
+ isFollowed = false
),
- awaitItem()
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList(),
+ ),
+ viewModel.feedState.value
+ )
+
+ viewModel.updateAuthorSelection("1", isChecked = true)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = true
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[1],
+ isSaved = false
+ ),
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[2],
+ isSaved = false
+ )
)
- }
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateTopicSelection("1", isChecked = true)
- viewModel.updateTopicSelection("1", isChecked = false)
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateTopicSelection("1", isChecked = true)
+ viewModel.updateTopicSelection("1", isChecked = false)
+
+ advanceUntilIdle()
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
- )
+ isFollowed = false
),
- expectMostRecentItem()
- )
- }
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateAuthorSelection("1", isChecked = true)
- viewModel.updateAuthorSelection("1", isChecked = false)
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- ),
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateAuthorSelection("1", isChecked = true)
+ viewModel.updateAuthorSelection("1", isChecked = false)
+
+ assertEquals(
+
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
- )
+ isFollowed = false
),
- expectMostRecentItem()
- )
- }
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
+ ),
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingTopicsOnly() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateTopicSelection("1", isChecked = true)
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- viewModel.saveFollowedInterests()
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[1],
- isSaved = false,
- ),
- SaveableNewsResource(
- newsResource = sampleNewsResources[2],
- isSaved = false,
- )
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateTopicSelection("1", isChecked = true)
+
+ viewModel.saveFollowedInterests()
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[1],
+ isSaved = false,
),
- expectMostRecentItem()
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[2],
+ isSaved = false,
+ )
)
- assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics())
- assertEquals(emptySet(), authorsRepository.getCurrentFollowedAuthors())
- }
+ ),
+ viewModel.feedState.value
+ )
+ assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics())
+ assertEquals(emptySet(), userDataRepository.getCurrentFollowedAuthors())
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingAuthorsOnly() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateAuthorSelection("0", isChecked = true)
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- viewModel.saveFollowedInterests()
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[0],
- isSaved = false
- ),
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateAuthorSelection("0", isChecked = true)
+
+ viewModel.saveFollowedInterests()
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[0],
+ isSaved = false
),
- expectMostRecentItem()
)
- assertEquals(emptySet(), topicsRepository.getCurrentFollowedTopics())
- assertEquals(setOf("0"), authorsRepository.getCurrentFollowedAuthors())
- }
+ ),
+ viewModel.feedState.value
+ )
+ assertEquals(emptySet(), userDataRepository.getCurrentFollowedTopics())
+ assertEquals(setOf("0"), userDataRepository.getCurrentFollowedAuthors())
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateAuthorSelection("1", isChecked = true)
- viewModel.updateTopicSelection("1", isChecked = true)
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- viewModel.saveFollowedInterests()
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[1],
- isSaved = false
- ),
- SaveableNewsResource(
- newsResource = sampleNewsResources[2],
- isSaved = false
- )
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateAuthorSelection("1", isChecked = true)
+ viewModel.updateTopicSelection("1", isChecked = true)
+
+ viewModel.saveFollowedInterests()
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[1],
+ isSaved = false
),
- expectMostRecentItem()
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[2],
+ isSaved = false
+ )
)
- assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics())
- assertEquals(setOf("1"), authorsRepository.getCurrentFollowedAuthors())
- }
+ ),
+ viewModel.feedState.value
+ )
+ assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics())
+ assertEquals(setOf("1"), userDataRepository.getCurrentFollowedAuthors())
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun topicSelectionIsResetAfterSavingTopicsAndRemovingThem() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateTopicSelection("1", isChecked = true)
- viewModel.saveFollowedInterests()
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- topicsRepository.setFollowedTopicIds(emptySet())
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateTopicSelection("1", isChecked = true)
+ viewModel.saveFollowedInterests()
+
+ userDataRepository.setFollowedTopicIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
- )
+ isFollowed = false
),
-
- expectMostRecentItem()
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
)
- }
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun authorSelectionIsResetAfterSavingAuthorsAndRemovingThem() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(emptySet())
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(emptySet())
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateAuthorSelection("1", isChecked = true)
- viewModel.saveFollowedInterests()
-
- advanceUntilIdle()
- expectMostRecentItem()
-
- authorsRepository.setFollowedAuthorIds(emptySet())
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.WithInterestsSelection(
- topics = listOf(
- FollowableTopic(
- topic = Topic(
- id = "0",
- name = "Headlines",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "1",
- name = "UI",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- ),
- FollowableTopic(
- topic = Topic(
- id = "2",
- name = "Tools",
- shortDescription = "",
- longDescription = "long description",
- url = "URL",
- imageUrl = "image URL",
- ),
- isFollowed = false
- )
- ),
- authors = listOf(
- FollowableAuthor(
- author = Author(
- id = "0",
- name = "Android Dev",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "1",
- name = "Android Dev 2",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- ),
- FollowableAuthor(
- author = Author(
- id = "2",
- name = "Android Dev 3",
- imageUrl = "",
- twitter = "",
- mediumPage = "",
- bio = "",
- ),
- isFollowed = false
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(emptySet())
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(emptySet())
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateAuthorSelection("1", isChecked = true)
+ viewModel.saveFollowedInterests()
+
+ userDataRepository.setFollowedAuthorIds(emptySet())
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.WithInterestsSelection(
+ topics = listOf(
+ FollowableTopic(
+ topic = Topic(
+ id = "0",
+ name = "Headlines",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ ),
+ FollowableTopic(
+ topic = Topic(
+ id = "1",
+ name = "UI",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
),
- feedState = ForYouFeedUiState.Success(
- feed = emptyList()
- )
+ isFollowed = false
),
- expectMostRecentItem()
+ FollowableTopic(
+ topic = Topic(
+ id = "2",
+ name = "Tools",
+ shortDescription = "",
+ longDescription = "long description",
+ url = "URL",
+ imageUrl = "image URL",
+ ),
+ isFollowed = false
+ )
+ ),
+ authors = listOf(
+ FollowableAuthor(
+ author = Author(
+ id = "0",
+ name = "Android Dev",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "1",
+ name = "Android Dev 2",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ ),
+ FollowableAuthor(
+ author = Author(
+ id = "2",
+ name = "Android Dev 3",
+ imageUrl = "",
+ twitter = "",
+ mediumPage = "",
+ bio = "",
+ ),
+ isFollowed = false
+ )
)
- }
+ ),
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = emptyList()
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
@Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
- viewModel.uiState
- .test {
- topicsRepository.sendTopics(sampleTopics)
- topicsRepository.setFollowedTopicIds(setOf("1"))
- authorsRepository.sendAuthors(sampleAuthors)
- authorsRepository.setFollowedAuthorIds(setOf("1"))
- newsRepository.sendNewsResources(sampleNewsResources)
- viewModel.updateNewsResourceSaved("2", true)
-
- advanceUntilIdle()
- assertEquals(
- ForYouUiState(
- interestsSelectionState =
- ForYouInterestsSelectionUiState.NoInterestsSelection,
- feedState = ForYouFeedUiState.Success(
- feed = listOf(
- SaveableNewsResource(
- newsResource = sampleNewsResources[1],
- isSaved = true
- ),
- SaveableNewsResource(
- newsResource = sampleNewsResources[2],
- isSaved = false
- )
- )
- )
+ val collectJob1 =
+ launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() }
+ val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
+
+ topicsRepository.sendTopics(sampleTopics)
+ userDataRepository.setFollowedTopicIds(setOf("1"))
+ authorsRepository.sendAuthors(sampleAuthors)
+ userDataRepository.setFollowedAuthorIds(setOf("1"))
+ newsRepository.sendNewsResources(sampleNewsResources)
+ viewModel.updateNewsResourceSaved("2", true)
+
+ assertEquals(
+ ForYouInterestsSelectionUiState.NoInterestsSelection,
+ viewModel.interestsSelectionState.value
+ )
+ assertEquals(
+ ForYouFeedUiState.Success(
+ feed = listOf(
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[1],
+ isSaved = true
),
- expectMostRecentItem()
+ SaveableNewsResource(
+ newsResource = sampleNewsResources[2],
+ isSaved = false
+ )
)
- }
+ ),
+ viewModel.feedState.value
+ )
+
+ collectJob1.cancel()
+ collectJob2.cancel()
}
}
diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt
index ca1b03c71..61959a748 100644
--- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt
+++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt
@@ -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 {
diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt
index 61b160f5c..e7dabb09b 100644
--- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt
+++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt
@@ -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)
)
}
}
diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
index f2e419d53..c6c01c3ca 100644
--- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
+++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
@@ -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 = _tabState.asStateFlow()
val uiState: StateFlow = 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)
}
}
diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt
index 55efafc33..fb36fe1bc 100644
--- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt
+++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt
@@ -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))
}
}
}
diff --git a/feature-interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature-interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
index 4902e9b2e..c36625f43 100644
--- a/feature-interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
+++ b/feature-interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
@@ -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()
}
}
diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
index 3cb99d67e..c3fe5e332 100644
--- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
+++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
@@ -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) {
diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
index 9e1bba01b..d647e4bf7 100644
--- a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
+++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
@@ -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>> =
- topicsRepository.getFollowedTopicIdsStream().asResult()
+ userDataRepository.userDataStream
+ .map { it.followedTopics }
+ .asResult()
// Observe topic information
private val topic: Flow> = 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)
}
}
}
diff --git a/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
index db340c1f7..e29b33054 100644
--- a/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
+++ b/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
@@ -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()
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 96e869293..d8cd0cf88 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/lint/.gitignore b/lint/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/lint/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/lint/build.gradle b/lint/build.gradle
new file mode 100644
index 000000000..4574fdffd
--- /dev/null
+++ b/lint/build.gradle
@@ -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
+}
diff --git a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt
new file mode 100644
index 000000000..135c66407
--- /dev/null
+++ b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt
@@ -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> {
+ 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"
+ )
+ }
+ }
+}
diff --git a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt
new file mode 100644
index 000000000..152c07c28
--- /dev/null
+++ b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt
@@ -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"
+ )
+}
diff --git a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
new file mode 100644
index 000000000..4b8002da2
--- /dev/null
+++ b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
@@ -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
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 489ed6f8d..d61d01798 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -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")