Merge remote-tracking branch 'origin/vishesh211/bug-534-interests-multiple-click' into vishesh211/bug-534-interests-multiple-click

# Conflicts:
#	feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt
pull/568/head
vishesh 2 years ago
commit 0695fbdc27

@ -0,0 +1,6 @@
# https://editorconfig.org/
# This configuration is used by ktlint when spotless invokes it
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true

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

@ -66,7 +66,7 @@ fun NiaCatalog() {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
item { item {
Text( Text(
@ -93,19 +93,19 @@ fun NiaCatalog() {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaButton( NiaButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
@ -119,21 +119,21 @@ fun NiaCatalog() {
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
} }
} }
@ -146,7 +146,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
@ -154,7 +154,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
@ -162,7 +162,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
} }
} }
@ -173,14 +173,14 @@ fun NiaCatalog() {
text = { Text("Enabled") }, text = { Text("Enabled") },
items = listOf("Item 1", "Item 2", "Item 3"), items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {}, onItemClick = {},
itemText = { item -> Text(item) } itemText = { item -> Text(item) },
) )
NiaDropdownMenuButton( NiaDropdownMenuButton(
text = { Text("Disabled") }, text = { Text("Disabled") },
items = listOf("Item 1", "Item 2", "Item 3"), items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {}, onItemClick = {},
itemText = { item -> Text(item) }, itemText = { item -> Text(item) },
enabled = false enabled = false,
) )
} }
} }
@ -191,25 +191,25 @@ fun NiaCatalog() {
NiaFilterChip( NiaFilterChip(
selected = firstChecked, selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked }, onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled") } label = { Text(text = "Enabled") },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip( NiaFilterChip(
selected = secondChecked, selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked }, onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled") } label = { Text(text = "Enabled") },
) )
NiaFilterChip( NiaFilterChip(
selected = false, selected = false,
onSelectedChange = {}, onSelectedChange = {},
enabled = false, enabled = false,
label = { Text(text = "Disabled") } label = { Text(text = "Disabled") },
) )
NiaFilterChip( NiaFilterChip(
selected = true, selected = true,
onSelectedChange = {}, onSelectedChange = {},
enabled = false, enabled = false,
label = { Text(text = "Disabled") } label = { Text(text = "Disabled") },
) )
} }
} }
@ -223,15 +223,15 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaIconToggleButton( NiaIconToggleButton(
@ -240,15 +240,15 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
NiaIconToggleButton( NiaIconToggleButton(
checked = false, checked = false,
@ -256,16 +256,16 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
}, },
enabled = false enabled = false,
) )
NiaIconToggleButton( NiaIconToggleButton(
checked = true, checked = true,
@ -273,16 +273,16 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
}, },
enabled = false enabled = false,
) )
} }
} }
@ -294,21 +294,21 @@ fun NiaCatalog() {
expanded = firstExpanded, expanded = firstExpanded,
onExpandedChange = { expanded -> firstExpanded = expanded }, onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
) )
var secondExpanded by remember { mutableStateOf(true) } var secondExpanded by remember { mutableStateOf(true) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = secondExpanded, expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded }, onExpandedChange = { expanded -> secondExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
) )
NiaViewToggleButton( NiaViewToggleButton(
expanded = false, expanded = false,
onExpandedChange = {}, onExpandedChange = {},
compactText = { Text(text = "Disabled") }, compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") }, expandedText = { Text(text = "Disabled") },
enabled = false enabled = false,
) )
} }
} }
@ -330,7 +330,7 @@ fun NiaCatalog() {
text = { Text(text = "Topic 1".uppercase()) }, text = { Text(text = "Topic 1".uppercase()) },
followText = { Text(text = "Follow") }, followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") }, unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") } browseText = { Text(text = "Browse topic") },
) )
var secondFollowed by remember { mutableStateOf(true) } var secondFollowed by remember { mutableStateOf(true) }
NiaTopicTag( NiaTopicTag(
@ -345,7 +345,7 @@ fun NiaCatalog() {
text = { Text(text = "Topic 2".uppercase()) }, text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") }, followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") }, unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") } browseText = { Text(text = "Browse topic") },
) )
NiaTopicTag( NiaTopicTag(
expanded = false, expanded = false,
@ -355,7 +355,7 @@ fun NiaCatalog() {
onUnfollowClick = {}, onUnfollowClick = {},
onBrowseClick = {}, onBrowseClick = {},
text = { Text(text = "Disabled".uppercase()) }, text = { Text(text = "Disabled".uppercase()) },
enabled = false enabled = false,
) )
} }
} }
@ -368,7 +368,7 @@ fun NiaCatalog() {
NiaTab( NiaTab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index }, onClick = { selectedTabIndex = index },
text = { Text(text = title) } text = { Text(text = title) },
) )
} }
} }
@ -380,12 +380,12 @@ fun NiaCatalog() {
val icons = listOf( val icons = listOf(
NiaIcons.UpcomingBorder, NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder, NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder NiaIcons.BookmarksBorder,
) )
val selectedIcons = listOf( val selectedIcons = listOf(
NiaIcons.Upcoming, NiaIcons.Upcoming,
NiaIcons.MenuBook, NiaIcons.MenuBook,
NiaIcons.Bookmarks NiaIcons.Bookmarks,
) )
val tagIcon = NiaIcons.Tag val tagIcon = NiaIcons.Tag
NiaNavigationBar { NiaNavigationBar {
@ -397,7 +397,7 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = icons[index]), painter = painterResource(id = icons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
@ -407,13 +407,13 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = selectedIcons[index]), painter = painterResource(id = selectedIcons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
label = { Text(item) }, label = { Text(item) },
selected = selectedItem == index, selected = selectedItem == index,
onClick = { selectedItem = index } onClick = { selectedItem = index },
) )
} }
} }

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

@ -29,10 +29,6 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -40,6 +36,10 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
/** /**
* Tests all the navigation flows that are handled by the navigation library. * Tests all the navigation flows that are handled by the navigation library.
@ -57,7 +57,8 @@ class NavigationTest {
* Create a temporary folder used to create a Data Store file. This guarantees that * Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash. * the file is removed in between each test, preventing a crash.
*/ */
@BindValue @get:Rule(order = 1) @BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
@ -165,7 +166,6 @@ class NavigationTest {
@Test @Test
fun topLevelDestinations_showTopBarWithTitle() { fun topLevelDestinations_showTopBarWithTitle() {
composeTestRule.apply { composeTestRule.apply {
// Verify that the top bar contains the app name on the first screen. // Verify that the top bar contains the app name on the first screen.
onNodeWithText(appName).assertExists() onNodeWithText(appName).assertExists()
@ -207,7 +207,6 @@ class NavigationTest {
@Test @Test
fun whenSettingsDialogDismissed_previousScreenIsDisplayed() { fun whenSettingsDialogDismissed_previousScreenIsDisplayed() {
composeTestRule.apply { composeTestRule.apply {
// Navigate to the saved screen, open the settings dialog, then close it. // Navigate to the saved screen, open the settings dialog, then close it.
onNodeWithText(saved).performClick() onNodeWithText(saved).performClick()
onNodeWithContentDescription(settings).performClick() onNodeWithContentDescription(settings).performClick()

@ -30,11 +30,11 @@ import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActi
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/** /**
* Tests that the navigation UI is rendered correctly on different screen sizes. * Tests that the navigation UI is rendered correctly on different screen sizes.
@ -53,7 +53,8 @@ class NavigationUiTest {
* Create a temporary folder used to create a Data Store file. This guarantees that * Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash. * the file is removed in between each test, preventing a crash.
*/ */
@BindValue @get:Rule(order = 1) @BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
@ -77,9 +78,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -90,41 +91,41 @@ class NavigationUiTest {
} }
@Test @Test
fun mediumWidth_compactHeight_showsNavigationBar() { fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent { composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) { TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
} }
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
} }
@Test @Test
fun expandedWidth_compactHeight_showsNavigationBar() { fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent { composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) { TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
} }
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
} }
@Test @Test
@ -134,9 +135,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -153,9 +154,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -172,9 +173,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -191,9 +192,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -210,9 +211,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -229,9 +230,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }

@ -31,15 +31,15 @@ import androidx.navigation.compose.composable
import androidx.navigation.createGraph import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController import androidx.navigation.testing.TestNavHostController
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** /**
* Tests [NiaAppState]. * Tests [NiaAppState].
@ -70,7 +70,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = navController, navController = navController,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -91,7 +91,7 @@ class NiaAppStateTest {
composeTestRule.setContent { composeTestRule.setContent {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
@ -108,7 +108,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -123,7 +123,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -133,13 +133,12 @@ class NiaAppStateTest {
@Test @Test
fun niaAppState_showNavRail_large() = runTest { fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -149,13 +148,12 @@ class NiaAppStateTest {
@Test @Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -163,7 +161,7 @@ class NiaAppStateTest {
networkMonitor.setConnected(false) networkMonitor.setConnected(false)
assertEquals( assertEquals(
true, true,
state.isOffline.value state.isOffline.value,
) )
} }

File diff suppressed because it is too large Load Diff

@ -43,10 +43,10 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
@ -106,7 +106,8 @@ class MainActivity : ComponentActivity() {
NiaTheme( NiaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState) androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) { ) {
NiaApp( NiaApp(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
@ -141,6 +142,17 @@ private fun shouldUseAndroidTheme(
} }
} }
/**
* Returns `true` if the dynamic color is disabled, as a function of the [uiState].
*/
@Composable
private fun shouldDisableDynamicTheming(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
Loading -> false
is Success -> !uiState.userData.useDynamicColor
}
/** /**
* Returns `true` if dark theme should be used, as a function of the [uiState] and the * Returns `true` if dark theme should be used, as a function of the [uiState] and the
* current system context. * current system context.

@ -23,22 +23,22 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainActivityViewModel @Inject constructor( class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository userDataRepository: UserDataRepository,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map { val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it) Success(it)
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
initialValue = Loading, initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000) started = SharingStarted.WhileSubscribed(5_000),
) )
} }

@ -47,7 +47,7 @@ object JankStatsModule {
@Provides @Provides
fun providesJankStats( fun providesJankStats(
window: Window, window: Window,
frameListener: JankStats.OnFrameListener frameListener: JankStats.OnFrameListener,
): JankStats { ): JankStats {
return JankStats.createAndTrack(window, frameListener) return JankStats.createAndTrack(window, frameListener)
} }

@ -39,7 +39,7 @@ fun NiaNavHost(
navController: NavHostController, navController: NavHostController,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute startDestination: String = forYouNavigationRoute,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@ -54,7 +54,7 @@ fun NiaNavHost(
}, },
nestedGraphs = { nestedGraphs = {
topicScreen(onBackClick) topicScreen(onBackClick)
} },
) )
} }
} }

@ -34,24 +34,24 @@ enum class TopLevelDestination(
val selectedIcon: Icon, val selectedIcon: Icon,
val unselectedIcon: Icon, val unselectedIcon: Icon,
val iconTextId: Int, val iconTextId: Int,
val titleTextId: Int val titleTextId: Int,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you, iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name titleTextId = R.string.app_name,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved, iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved titleTextId = bookmarksR.string.saved,
), ),
INTERESTS( INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests, iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests titleTextId = interestsR.string.interests,
) ),
} }

@ -68,16 +68,16 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class ExperimentalLifecycleComposeApi::class,
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
@ -85,7 +85,7 @@ fun NiaApp(
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass windowSizeClass = windowSizeClass,
), ),
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
@ -106,15 +106,17 @@ fun NiaApp(
// If user is not connected to the internet show a snack bar to inform them. // If user is not connected to the internet show a snack bar to inform them.
val notConnectedMessage = stringResource(R.string.not_connected) val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) { LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar( if (isOffline) {
message = notConnectedMessage, snackbarHostState.showSnackbar(
duration = Indefinite message = notConnectedMessage,
) duration = Indefinite,
)
}
} }
if (appState.shouldShowSettingsDialog) { if (appState.shouldShowSettingsDialog) {
SettingsDialog( SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) } onDismiss = { appState.setShowSettingsDialog(false) },
) )
} }
@ -132,10 +134,10 @@ fun NiaApp(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar") modifier = Modifier.testTag("NiaBottomBar"),
) )
} }
} },
) { padding -> ) { padding ->
Row( Row(
Modifier Modifier
@ -144,9 +146,9 @@ fun NiaApp(
.consumedWindowInsets(padding) .consumedWindowInsets(padding)
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only( WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal WindowInsetsSides.Horizontal,
) ),
) ),
) { ) {
if (appState.shouldShowNavRail) { if (appState.shouldShowNavRail) {
NiaNavRail( NiaNavRail(
@ -155,7 +157,7 @@ fun NiaApp(
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier modifier = Modifier
.testTag("NiaNavRail") .testTag("NiaNavRail")
.safeDrawingPadding() .safeDrawingPadding(),
) )
} }
@ -167,18 +169,18 @@ fun NiaApp(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings, actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description id = settingsR.string.top_app_bar_action_icon_description,
), ),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent,
), ),
onActionClick = { appState.setShowSettingsDialog(true) } onActionClick = { appState.setShowSettingsDialog(true) },
) )
} }
NiaNavHost( NiaNavHost(
navController = appState.navController, navController = appState.navController,
onBackClick = appState::onBackClick onBackClick = appState::onBackClick,
) )
} }
@ -212,15 +214,15 @@ private fun NiaNavRail(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null contentDescription = null,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -231,10 +233,10 @@ private fun NiaBottomBar(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?, currentDestination: NavDestination?,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
NiaNavigationBar( NiaNavigationBar(
modifier = modifier modifier = modifier,
) { ) {
destinations.forEach { destination -> destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
@ -250,16 +252,16 @@ private fun NiaBottomBar(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null contentDescription = null,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -56,7 +55,7 @@ fun rememberNiaAppState(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(navController)
return remember(navController, coroutineScope, windowSizeClass, networkMonitor) { return remember(navController, coroutineScope, windowSizeClass, networkMonitor) {
@ -87,8 +86,7 @@ class NiaAppState(
private set private set
val shouldShowBottomBar: Boolean val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
val shouldShowNavRail: Boolean val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar get() = !shouldShowBottomBar
@ -98,7 +96,7 @@ class NiaAppState(
.stateIn( .stateIn(
scope = coroutineScope, scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = false initialValue = false,
) )
/** /**

@ -28,7 +28,7 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
*/ */
fun untilHasChildren( fun untilHasChildren(
childCount: Int = 1, childCount: Int = 1,
op: HasChildrenOp = AT_LEAST op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> { ): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() { return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean { override fun apply(element: UiObject2): Boolean {
@ -44,5 +44,5 @@ fun untilHasChildren(
enum class HasChildrenOp { enum class HasChildrenOp {
AT_LEAST, AT_LEAST,
EXACTLY, EXACTLY,
AT_MOST AT_MOST,
} }

@ -18,11 +18,12 @@ package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.ExperimentalBaselineProfilesApi import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -46,24 +47,16 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey // Scroll the feed critical user journey
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics(true)
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
// Navigate to saved screen // Navigate to saved screen
device.findObject(By.text("Saved")).click() goToBookmarksScreen()
device.waitForIdle()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it // TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp() // bookmarksScrollFeedDownUp()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() goToInterestsScreen()
device.waitForIdle()
interestsScrollTopicsDownUp() interestsScrollTopicsDownUp()
// Navigate to people tab
device.findObject(By.text("People")).click()
device.waitForIdle()
interestsScrollPeopleDownUp()
} }
} }

@ -18,8 +18,18 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToBookmarksScreen() {
device.findObject(By.text("Saved")).click()
device.waitForIdle()
// Wait until saved title are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000)
}
fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() { fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
val feedList = device.findObject(By.res("bookmarks:feed")) val feedList = device.findObject(By.res("bookmarks:feed"))
device.flingElementDownUp(feedList) device.flingElementDownUp(feedList)

@ -23,12 +23,65 @@ import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.forYouWaitForContent() { fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if authors are loaded // Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("forYou:loadingWheel")), 5_000) device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet // Sometimes, the loading wheel is gone, but the content is not loaded yet
// So we'll wait here for authors to be sure // So we'll wait here for topics to be sure
val obj = device.findObject(By.res("forYou:authors")) val obj = device.findObject(By.res("forYou:topicSelection"))
obj.wait(untilHasChildren(), 30_000) // Timeout here is quite big, because sometimes data loading takes a long time!
obj.wait(untilHasChildren(), 60_000)
}
/**
* Selects some topics, which will show the feed content for them.
* [recheckTopicsIfChecked] Topics may be already checked from the previous iteration.
*/
fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = false) {
val topics = device.findObject(By.res("forYou:topicSelection"))
// Set gesture margin from sides not to trigger system gesture navigation
val horizontalMargin = 10 * topics.visibleBounds.width() / 100
topics.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0)
// Select some topics to show some feed content
var index = 0
var visited = 0
while (visited < 3) {
// Selecting some topics, which will populate items in the feed.
val topic = topics.children[index % topics.childCount]
// Find the checkable element to figure out whether it's checked or not
val topicCheckIcon = topic.findObject(By.checkable(true))
// Topic icon may not be visible if it's out of the screen boundaries
// If that's the case, let's try another index
if (topicCheckIcon == null) {
index++
continue
}
when {
// Topic wasn't checked, so just do that
!topicCheckIcon.isChecked -> {
topic.click()
device.waitForIdle()
}
// Topic was checked already and we want to recheck it, so just do it twice
recheckTopicsIfChecked -> {
repeat(2) {
topic.click()
device.waitForIdle()
}
}
else -> {
// Topic is checked, but we don't recheck it
}
}
index++
visited++
}
} }
fun MacrobenchmarkScope.forYouScrollFeedDownUp() { fun MacrobenchmarkScope.forYouScrollFeedDownUp() {

@ -47,9 +47,10 @@ class ScrollForYouFeedBenchmark {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
} },
) { ) {
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics()
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
} }
} }

@ -21,16 +21,23 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToInterestsScreen() {
device.findObject(By.text("Interests")).click()
device.waitForIdle()
// Wait until interests are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Interests")), 2_000)
// Wait until content is loaded by checking if interests are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000)
}
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() { fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
val topicsList = device.findObject(By.res("interests:topics")) val topicsList = device.findObject(By.res("interests:topics"))
device.flingElementDownUp(topicsList) device.flingElementDownUp(topicsList)
} }
fun MacrobenchmarkScope.interestsScrollPeopleDownUp() {
val peopleList = device.findObject(By.res("interests:people"))
device.flingElementDownUp(peopleList)
}
fun MacrobenchmarkScope.interestsWaitForTopics() { fun MacrobenchmarkScope.interestsWaitForTopics() {
device.wait(Until.hasObject(By.text("Accessibility")), 30_000) device.wait(Until.hasObject(By.text("Accessibility")), 30_000)
} }

@ -51,7 +51,7 @@ class TopicsScreenRecompositionBenchmark {
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()
} },
) { ) {
interestsWaitForTopics() interestsWaitForTopics()
repeat(3) { repeat(3) {

@ -66,7 +66,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
@Test @Test
fun startupBaselineProfileDisabled() = startup( fun startupBaselineProfileDisabled() = startup(
CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1) CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1),
) )
@Test @Test
@ -83,7 +83,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
startupMode = startupMode, startupMode = startupMode,
setupBlock = { setupBlock = {
pressHome() pressHome()
} },
) { ) {
startActivityAndWait() startActivityAndWait()
// Waits until the content is ready to capture Time To Full Display // Waits until the content is ready to capture Time To Full Display

@ -24,5 +24,5 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
annotation class Dispatcher(val niaDispatcher: NiaDispatchers) annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
enum class NiaDispatchers { enum class NiaDispatchers {
IO IO,
} }

@ -17,10 +17,10 @@
package com.google.samples.apps.nowinandroid.core.result package com.google.samples.apps.nowinandroid.core.result
import app.cash.turbine.test import app.cash.turbine.test
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class ResultKtTest { class ResultKtTest {
@ -38,11 +38,12 @@ class ResultKtTest {
when (val errorResult = awaitItem()) { when (val errorResult = awaitItem()) {
is Result.Error -> assertEquals( is Result.Error -> assertEquals(
"Test Done", "Test Done",
errorResult.exception?.message errorResult.exception?.message,
) )
Result.Loading, Result.Loading,
is Result.Success -> throw IllegalStateException( is Result.Success,
"The flow should have emitted an Error Result" -> throw IllegalStateException(
"The flow should have emitted an Error Result",
) )
} }

@ -17,9 +17,9 @@
package com.google.samples.apps.nowinandroid.core.data.test package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor { class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true) override val isOnline: Flow<Boolean> = flowOf(true)

@ -32,26 +32,26 @@ import dagger.hilt.testing.TestInstallIn
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataModule::class] replaces = [DataModule::class],
) )
interface TestDataModule { interface TestDataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository fakeTopicsRepository: FakeTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository fakeNewsRepository: FakeNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository userDataRepository: FakeUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.data
import android.util.Log import android.util.Log
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Interface marker for a class that manages synchronization between local data and a remote * Interface marker for a class that manages synchronization between local data and a remote
@ -62,7 +62,7 @@ private suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> =
Log.i( Log.i(
"suspendRunCatching", "suspendRunCatching",
"Failed to evaluate a suspendRunCatchingBlock. Returning failure Result", "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result",
exception exception,
) )
Result.failure(exception) Result.failure(exception)
} }
@ -116,10 +116,10 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>, flow4: Flow<T4>,
flow5: Flow<T5>, flow5: Flow<T5>,
flow6: Flow<T6>, flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine( ): Flow<R> = combine(
combine(flow, flow2, flow3, ::Triple), combine(flow, flow2, flow3, ::Triple),
combine(flow4, flow5, flow6, ::Triple) combine(flow4, flow5, flow6, ::Triple),
) { t1, t2 -> ) { t1, t2 ->
transform( transform(
t1.first, t1.first,
@ -127,6 +127,6 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
t1.third, t1.third,
t2.first, t2.first,
t2.second, t2.second,
t2.third t2.third,
) )
} }

@ -35,21 +35,21 @@ interface DataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository newsRepository: OfflineFirstNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -62,6 +62,6 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topics.map { topicId -> topics.map { topicId ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = id, newsResourceId = id,
topicId = topicId topicId = topicId,
) )
} }

@ -25,5 +25,5 @@ fun NetworkTopic.asEntity() = TopicEntity(
shortDescription = shortDescription, shortDescription = shortDescription,
longDescription = longDescription, longDescription = longDescription,
url = url, url = url,
imageUrl = imageUrl imageUrl = imageUrl,
) )

@ -30,9 +30,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Disk storage backed implementation of the [NewsRepository]. * Disk storage backed implementation of the [NewsRepository].
@ -49,9 +49,9 @@ class OfflineFirstNewsRepository @Inject constructor(
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources( override fun getNewsResources(
filterTopicIds: Set<String> filterTopicIds: Set<String>,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources( ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterTopicIds = filterTopicIds filterTopicIds = filterTopicIds,
) )
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
@ -74,18 +74,18 @@ class OfflineFirstNewsRepository @Inject constructor(
topicEntities = networkNewsResources topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells) .map(NetworkNewsResource::topicEntityShells)
.flatten() .flatten()
.distinctBy(TopicEntity::id) .distinctBy(TopicEntity::id),
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity),
) )
newsResourceDao.insertOrIgnoreTopicCrossRefEntities( newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences) .map(NetworkNewsResource::topicCrossReferences)
.distinct() .distinct()
.flatten() .flatten(),
) )
} },
) )
} }

@ -26,9 +26,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Disk storage backed implementation of the [TopicsRepository]. * Disk storage backed implementation of the [TopicsRepository].
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds) val networkTopics = network.getTopics(ids = changedIds)
topicDao.upsertTopics( topicDao.upsertTopics(
entities = networkTopics.map(NetworkTopic::asEntity) entities = networkTopics.map(NetworkTopic::asEntity),
) )
} },
) )
} }

@ -20,11 +20,11 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor( class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository { ) : UserDataRepository {
override val userData: Flow<UserData> = override val userData: Flow<UserData> =
@ -45,6 +45,9 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) =
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) =
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

@ -53,6 +53,11 @@ interface UserDataRepository {
*/ */
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets the preferred dynamic color config.
*/
suspend fun setDynamicColorPreference(useDynamicColor: Boolean)
/** /**
* Sets whether the user has completed the onboarding process. * Sets whether the user has completed the onboarding process.
*/ */

@ -26,11 +26,11 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/** /**
* Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String. * Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.
@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.flowOn
*/ */
class FakeNewsRepository @Inject constructor( class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> = override fun getNewsResources(): Flow<List<NewsResource>> =
@ -48,7 +48,7 @@ class FakeNewsRepository @Inject constructor(
emit( emit(
datasource.getNewsResources() datasource.getNewsResources()
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel),
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)
@ -61,7 +61,7 @@ class FakeNewsRepository @Inject constructor(
.getNewsResources() .getNewsResources()
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() } .filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel),
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -22,12 +22,12 @@ 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.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and * Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and
@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.map
*/ */
class FakeTopicsRepository @Inject constructor( class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository { ) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = flow { override fun getTopics(): Flow<List<Topic>> = flow {
emit( emit(
@ -49,9 +49,9 @@ class FakeTopicsRepository @Inject constructor(
shortDescription = it.shortDescription, shortDescription = it.shortDescription,
longDescription = it.longDescription, longDescription = it.longDescription,
url = it.url, url = it.url,
imageUrl = it.imageUrl imageUrl = it.imageUrl,
) )
} },
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -21,8 +21,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/** /**
* Fake implementation of the [UserDataRepository] that returns hardcoded user data. * Fake implementation of the [UserDataRepository] that returns hardcoded user data.
@ -55,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
} }
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

@ -26,14 +26,14 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor( class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
) : NetworkMonitor { ) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>() val connectivityManager = context.getSystemService<ConnectivityManager>()
@ -54,7 +54,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(
network: Network, network: Network,
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities,
) { ) {
channel.trySend(connectivityManager.isCurrentlyConnected()) channel.trySend(connectivityManager.isCurrentlyConnected())
} }
@ -64,7 +64,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
Builder() Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(), .build(),
callback callback,
) )
channel.trySend(connectivityManager.isCurrentlyConnected()) channel.trySend(connectivityManager.isCurrentlyConnected())

@ -20,9 +20,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Art
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityKtTest { class NetworkEntityKtTest {

@ -35,13 +35,13 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstNewsRepositoryTest { class OfflineFirstNewsRepositoryTest {
@ -65,8 +65,8 @@ class OfflineFirstNewsRepositoryTest {
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
NiaPreferencesDataSource( NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(),
) ),
) )
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
@ -84,7 +84,7 @@ class OfflineFirstNewsRepositoryTest {
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources() subject.getNewsResources()
.first() .first(),
) )
} }
@ -100,7 +100,7 @@ class OfflineFirstNewsRepositoryTest {
subject.getNewsResources( subject.getNewsResources(
filterTopicIds = filteredInterestsIds, filterTopicIds = filteredInterestsIds,
) )
.first() .first(),
) )
assertEquals( assertEquals(
@ -108,7 +108,7 @@ class OfflineFirstNewsRepositoryTest {
subject.getNewsResources( subject.getNewsResources(
filterTopicIds = nonPresentInterestsIds, filterTopicIds = nonPresentInterestsIds,
) )
.first() .first(),
) )
} }
@ -127,13 +127,13 @@ class OfflineFirstNewsRepositoryTest {
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id) newsResourcesFromDb.map(NewsResource::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion synchronizer.getChangeListVersions().newsResourceVersion,
) )
} }
@ -155,7 +155,7 @@ class OfflineFirstNewsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.NewsResources, collectionType = CollectionType.NewsResources,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
@ -168,13 +168,13 @@ class OfflineFirstNewsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally // Assert that items marked deleted on the network have been deleted locally
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems, newsResourcesFromNetwork.map(NewsResource::id) - deletedItems,
newsResourcesFromDb.map(NewsResource::id) newsResourcesFromDb.map(NewsResource::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion synchronizer.getChangeListVersions().newsResourceVersion,
) )
} }
@ -190,7 +190,7 @@ class OfflineFirstNewsRepositoryTest {
val changeList = network.changeListsAfter( val changeList = network.changeListsAfter(
CollectionType.NewsResources, CollectionType.NewsResources,
version = 7 version = 7,
) )
val changeListIds = changeList val changeListIds = changeList
.map(NetworkChangeList::id) .map(NetworkChangeList::id)
@ -207,13 +207,13 @@ class OfflineFirstNewsRepositoryTest {
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id) newsResourcesFromDb.map(NewsResource::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
changeList.last().changeListVersion, changeList.last().changeListVersion,
synchronizer.getChangeListVersions().newsResourceVersion synchronizer.getChangeListVersions().newsResourceVersion,
) )
} }
@ -228,7 +228,7 @@ class OfflineFirstNewsRepositoryTest {
.flatten() .flatten()
.distinctBy(TopicEntity::id), .distinctBy(TopicEntity::id),
topicDao.getTopicEntities() topicDao.getTopicEntities()
.first() .first(),
) )
} }
@ -242,7 +242,7 @@ class OfflineFirstNewsRepositoryTest {
.map(NetworkNewsResource::topicCrossReferences) .map(NetworkNewsResource::topicCrossReferences)
.distinct() .distinct()
.flatten(), .flatten(),
newsResourceDao.topicCrossReferences newsResourceDao.topicCrossReferences,
) )
} }
} }

@ -28,13 +28,13 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest { class OfflineFirstTopicsRepositoryTest {
@ -56,13 +56,13 @@ class OfflineFirstTopicsRepositoryTest {
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource( niaPreferences = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(),
) )
synchronizer = TestSynchronizer(niaPreferences) synchronizer = TestSynchronizer(niaPreferences)
subject = OfflineFirstTopicsRepository( subject = OfflineFirstTopicsRepository(
topicDao = topicDao, topicDao = topicDao,
network = network network = network,
) )
} }
@ -74,7 +74,7 @@ class OfflineFirstTopicsRepositoryTest {
.first() .first()
.map(TopicEntity::asExternalModel), .map(TopicEntity::asExternalModel),
subject.getTopics() subject.getTopics()
.first() .first(),
) )
} }
@ -91,13 +91,13 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@ -121,13 +121,13 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@ -149,7 +149,7 @@ class OfflineFirstTopicsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.Topics, collectionType = CollectionType.Topics,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
@ -162,13 +162,13 @@ class OfflineFirstTopicsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally // Assert that items marked deleted on the network have been deleted locally
assertEquals( assertEquals(
networkTopics.map(Topic::id) - deletedItems, networkTopics.map(Topic::id) - deletedItems,
dbTopics.map(Topic::id) dbTopics.map(Topic::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
} }

@ -21,9 +21,6 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -31,6 +28,9 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest { class OfflineFirstUserDataRepositoryTest {
private lateinit var subject: OfflineFirstUserDataRepository private lateinit var subject: OfflineFirstUserDataRepository
@ -43,11 +43,11 @@ class OfflineFirstUserDataRepositoryTest {
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource( niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(),
) )
subject = OfflineFirstUserDataRepository( subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource niaPreferencesDataSource = niaPreferencesDataSource,
) )
} }
@ -60,9 +60,10 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false useDynamicColor = false,
shouldHideOnboarding = false,
), ),
subject.userData.first() subject.userData.first(),
) )
} }
@ -75,7 +76,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0"), setOf("0"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true) subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
@ -84,7 +85,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"), setOf("0", "1"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
@ -93,7 +94,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@ -106,7 +107,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("1", "2"), setOf("1", "2"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
@ -115,7 +116,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@ -128,7 +129,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0"), setOf("0"),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true) subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
@ -137,7 +138,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"), setOf("0", "1"),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
assertEquals( assertEquals(
@ -146,7 +147,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
} }
@ -159,14 +160,34 @@ class OfflineFirstUserDataRepositoryTest {
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
subject.userData subject.userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
) )
assertEquals( assertEquals(
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
niaPreferencesDataSource niaPreferencesDataSource
.userData .userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() =
runTest {
subject.setDynamicColorPreference(true)
assertEquals(
true,
subject.userData
.map { it.useDynamicColor }
.first(),
)
assertEquals(
true,
niaPreferencesDataSource
.userData
.map { it.useDynamicColor }
.first(),
) )
} }
@ -179,14 +200,14 @@ class OfflineFirstUserDataRepositoryTest {
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
subject.userData subject.userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
assertEquals( assertEquals(
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
niaPreferencesDataSource niaPreferencesDataSource
.userData .userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
} }

@ -24,12 +24,12 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
* Test synchronizer that delegates to [NiaPreferencesDataSource] * Test synchronizer that delegates to [NiaPreferencesDataSource]
*/ */
class TestSynchronizer( class TestSynchronizer(
private val niaPreferences: NiaPreferencesDataSource private val niaPreferences: NiaPreferencesDataSource,
) : Synchronizer { ) : Synchronizer {
override suspend fun getChangeListVersions(): ChangeListVersions = override suspend fun getChangeListVersions(): ChangeListVersions =
niaPreferences.getChangeListVersions() niaPreferences.getChangeListVersions()
override suspend fun updateChangeListVersions( override suspend fun updateChangeListVersions(
update: ChangeListVersions.() -> ChangeListVersions update: ChangeListVersions.() -> ChangeListVersions,
) = niaPreferences.updateChangeListVersion(update) ) = niaPreferences.updateChangeListVersion(update)
} }

@ -46,8 +46,8 @@ class TestNewsResourceDao : NewsResourceDao {
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
) ),
) ),
) )
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@ -58,7 +58,7 @@ class TestNewsResourceDao : NewsResourceDao {
} }
override fun getNewsResources( override fun getNewsResources(
filterTopicIds: Set<String> filterTopicIds: Set<String>,
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResources() getNewsResources()
.map { resources -> .map { resources ->
@ -68,7 +68,7 @@ class TestNewsResourceDao : NewsResourceDao {
} }
override suspend fun insertOrIgnoreNewsResources( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity> entities: List<NewsResourceEntity>,
): List<Long> { ): List<Long> {
entitiesStateFlow.value = entities entitiesStateFlow.value = entities
// Assume no conflicts on insert // Assume no conflicts on insert
@ -84,7 +84,7 @@ class TestNewsResourceDao : NewsResourceDao {
} }
override suspend fun insertOrIgnoreTopicCrossRefEntities( override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef> newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) { ) {
topicCrossReferences = newsResourceTopicCrossReferences topicCrossReferences = newsResourceTopicCrossReferences
} }
@ -107,6 +107,6 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )

@ -27,7 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType { enum class CollectionType {
Topics, Topics,
NewsResources NewsResources,
} }
/** /**
@ -37,7 +37,7 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource( private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(), UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true } Json { ignoreUnknownKeys = true },
) )
private val allTopics = runBlocking { source.getTopics() } private val allTopics = runBlocking { source.getTopics() }
@ -54,13 +54,13 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds( allTopics.matchIds(
ids = ids, ids = ids,
idGetter = NetworkTopic::id idGetter = NetworkTopic::id,
) )
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds( allNewsResources.matchIds(
ids = ids, ids = ids,
idGetter = NetworkNewsResource::id idGetter = NetworkNewsResource::id,
) )
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
@ -102,7 +102,7 @@ fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> =
*/ */
private fun <T> List<T>.matchIds( private fun <T> List<T>.matchIds(
ids: List<String>?, ids: List<String>?,
idGetter: (T) -> String idGetter: (T) -> String,
) = when (ids) { ) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } }
@ -113,7 +113,7 @@ private fun <T> List<T>.matchIds(
* [after] simulates which models have changed by excluding items before it * [after] simulates which models have changed by excluding items before it
*/ */
private fun <T> List<T>.mapToChangeList( private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String idGetter: (T) -> String,
) = mapIndexed { index, item -> ) = mapIndexed { index, item ->
NetworkChangeList( NetworkChangeList(
id = idGetter(item), id = idGetter(item),

@ -37,8 +37,8 @@ class TestTopicDao : TopicDao {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
) ),
) )
override fun getTopicEntity(topicId: String): Flow<TopicEntity> { override fun getTopicEntity(topicId: String): Flow<TopicEntity> {

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.database.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class PopulatedNewsResourceKtTest { class PopulatedNewsResourceKtTest {
@Test @Test
@ -44,7 +44,7 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )
val newsResource = populatedNewsResource.asExternalModel() val newsResource = populatedNewsResource.asExternalModel()
@ -66,10 +66,10 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
) ),
), ),
newsResource newsResource,
) )
} }
} }

@ -17,8 +17,8 @@
package com.google.samples.apps.nowinandroid.core.database.util package com.google.samples.apps.nowinandroid.core.database.util
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlin.test.assertEquals
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceTypeConverterTest { class NewsResourceTypeConverterTest {
@ -26,7 +26,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_video() { fun test_room_news_resource_type_converter_for_video() {
assertEquals( assertEquals(
NewsResourceType.Video, NewsResourceType.Video,
NewsResourceTypeConverter().stringToNewsResourceType("Video 📺") NewsResourceTypeConverter().stringToNewsResourceType("Video 📺"),
) )
} }
@ -34,7 +34,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_article() { fun test_room_news_resource_type_converter_for_article() {
assertEquals( assertEquals(
NewsResourceType.Article, NewsResourceType.Article,
NewsResourceTypeConverter().stringToNewsResourceType("Article 📚") NewsResourceTypeConverter().stringToNewsResourceType("Article 📚"),
) )
} }
@ -42,7 +42,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_api_change() { fun test_room_news_resource_type_converter_for_api_change() {
assertEquals( assertEquals(
NewsResourceType.APIChange, NewsResourceType.APIChange,
NewsResourceTypeConverter().stringToNewsResourceType("API change") NewsResourceTypeConverter().stringToNewsResourceType("API change"),
) )
} }
@ -50,7 +50,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_codelab() { fun test_room_news_resource_type_converter_for_codelab() {
assertEquals( assertEquals(
NewsResourceType.Codelab, NewsResourceType.Codelab,
NewsResourceTypeConverter().stringToNewsResourceType("Codelab") NewsResourceTypeConverter().stringToNewsResourceType("Codelab"),
) )
} }
@ -58,7 +58,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_podcast() { fun test_room_news_resource_type_converter_for_podcast() {
assertEquals( assertEquals(
NewsResourceType.Podcast, NewsResourceType.Podcast,
NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙") NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙"),
) )
} }
@ -66,7 +66,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_docs() { fun test_room_news_resource_type_converter_for_docs() {
assertEquals( assertEquals(
NewsResourceType.Docs, NewsResourceType.Docs,
NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑") NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑"),
) )
} }
@ -74,7 +74,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_event() { fun test_room_news_resource_type_converter_for_event() {
assertEquals( assertEquals(
NewsResourceType.Event, NewsResourceType.Event,
NewsResourceTypeConverter().stringToNewsResourceType("Event 📆") NewsResourceTypeConverter().stringToNewsResourceType("Event 📆"),
) )
} }
@ -82,7 +82,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_dac() { fun test_room_news_resource_type_converter_for_dac() {
assertEquals( assertEquals(
NewsResourceType.DAC, NewsResourceType.DAC,
NewsResourceTypeConverter().stringToNewsResourceType("DAC") NewsResourceTypeConverter().stringToNewsResourceType("DAC"),
) )
} }
@ -90,7 +90,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_umm() { fun test_room_news_resource_type_converter_for_umm() {
assertEquals( assertEquals(
NewsResourceType.Unknown, NewsResourceType.Unknown,
NewsResourceTypeConverter().stringToNewsResourceType("umm") NewsResourceTypeConverter().stringToNewsResourceType("umm"),
) )
} }
} }

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

@ -25,12 +25,12 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopi
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity 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.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest { class NewsResourceDaoTest {
@ -43,7 +43,7 @@ class NewsResourceDaoTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder( db = Room.inMemoryDatabaseBuilder(
context, context,
NiaDatabase::class.java NiaDatabase::class.java,
).build() ).build()
newsResourceDao = db.newsResourceDao() newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao() topicDao = db.topicDao()
@ -70,7 +70,7 @@ class NewsResourceDaoTest {
), ),
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities,
) )
val savedNewsResourceEntities = newsResourceDao.getNewsResources() val savedNewsResourceEntities = newsResourceDao.getNewsResources()
@ -80,7 +80,7 @@ class NewsResourceDaoTest {
listOf(3L, 2L, 1L, 0L), listOf(3L, 2L, 1L, 0L),
savedNewsResourceEntities.map { savedNewsResourceEntities.map {
it.asExternalModel().publishDate.toEpochMilliseconds() it.asExternalModel().publishDate.toEpochMilliseconds()
} },
) )
} }
@ -89,11 +89,11 @@ class NewsResourceDaoTest {
val topicEntities = listOf( val topicEntities = listOf(
testTopicEntity( testTopicEntity(
id = "1", id = "1",
name = "1" name = "1",
), ),
testTopicEntity( testTopicEntity(
id = "2", id = "2",
name = "2" name = "2",
), ),
) )
val newsResourceEntities = listOf( val newsResourceEntities = listOf(
@ -117,18 +117,18 @@ class NewsResourceDaoTest {
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity -> val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = index.toString(), newsResourceId = index.toString(),
topicId = topicEntity.id topicId = topicEntity.id,
) )
} }
topicDao.insertOrIgnoreTopics( topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities topicEntities = topicEntities,
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities,
) )
newsResourceDao.insertOrIgnoreTopicCrossRefEntities( newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossRefEntities newsResourceTopicCrossRefEntities,
) )
val filteredNewsResources = newsResourceDao.getNewsResources( val filteredNewsResources = newsResourceDao.getNewsResources(
@ -139,7 +139,7 @@ class NewsResourceDaoTest {
assertEquals( assertEquals(
listOf("1", "0"), listOf("1", "0"),
filteredNewsResources.map { it.entity.id } filteredNewsResources.map { it.entity.id },
) )
} }
@ -169,7 +169,7 @@ class NewsResourceDaoTest {
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 } val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
newsResourceDao.deleteNewsResources( newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id) toDelete.map(NewsResourceEntity::id),
) )
assertEquals( assertEquals(
@ -177,26 +177,26 @@ class NewsResourceDaoTest {
.toSet(), .toSet(),
newsResourceDao.getNewsResources().first() newsResourceDao.getNewsResources().first()
.map { it.entity.id } .map { it.entity.id }
.toSet() .toSet(),
) )
} }
} }
private fun testTopicEntity( private fun testTopicEntity(
id: String = "0", id: String = "0",
name: String name: String,
) = TopicEntity( ) = TopicEntity(
id = id, id = id,
name = name, name = name,
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
url = "", url = "",
imageUrl = "" imageUrl = "",
) )
private fun testNewsResource( private fun testNewsResource(
id: String = "0", id: String = "0",
millisSinceEpoch: Long = 0 millisSinceEpoch: Long = 0,
) = NewsResourceEntity( ) = NewsResourceEntity(
id = id, id = id,
title = "", title = "",

@ -33,31 +33,31 @@ object DatabaseMigrations {
@RenameColumn( @RenameColumn(
tableName = "topics", tableName = "topics",
fromColumnName = "description", fromColumnName = "description",
toColumnName = "shortDescription" toColumnName = "shortDescription",
) )
class Schema2to3 : AutoMigrationSpec class Schema2to3 : AutoMigrationSpec
@DeleteColumn( @DeleteColumn(
tableName = "news_resources", tableName = "news_resources",
columnName = "episode_id" columnName = "episode_id",
) )
@DeleteTable.Entries( @DeleteTable.Entries(
DeleteTable( DeleteTable(
tableName = "episodes_authors" tableName = "episodes_authors",
), ),
DeleteTable( DeleteTable(
tableName = "episodes" tableName = "episodes",
) ),
) )
class Schema10to11 : AutoMigrationSpec class Schema10to11 : AutoMigrationSpec
@DeleteTable.Entries( @DeleteTable.Entries(
DeleteTable( DeleteTable(
tableName = "news_resources_authors" tableName = "news_resources_authors",
), ),
DeleteTable( DeleteTable(
tableName = "authors" tableName = "authors",
) ),
) )
class Schema11to12 : AutoMigrationSpec class Schema11to12 : AutoMigrationSpec
} }

@ -35,6 +35,6 @@ object DatabaseModule {
): NiaDatabase = Room.databaseBuilder( ): NiaDatabase = Room.databaseBuilder(
context, context,
NiaDatabase::class.java, NiaDatabase::class.java,
"nia-database" "nia-database",
).build() ).build()
} }

@ -46,7 +46,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class) AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
], ],
exportSchema = true, exportSchema = true,
) )

@ -39,7 +39,7 @@ interface NewsResourceDao {
value = """ value = """
SELECT * FROM news_resources SELECT * FROM news_resources
ORDER BY publish_date DESC ORDER BY publish_date DESC
""" """,
) )
fun getNewsResources(): Flow<List<PopulatedNewsResource>> fun getNewsResources(): Flow<List<PopulatedNewsResource>>
@ -53,7 +53,7 @@ interface NewsResourceDao {
WHERE topic_id IN (:filterTopicIds) WHERE topic_id IN (:filterTopicIds)
) )
ORDER BY publish_date DESC ORDER BY publish_date DESC
""" """,
) )
fun getNewsResources( fun getNewsResources(
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String> = emptySet(),
@ -79,7 +79,7 @@ interface NewsResourceDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities( suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef> newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) )
/** /**
@ -89,7 +89,7 @@ interface NewsResourceDao {
value = """ value = """
DELETE FROM news_resources DELETE FROM news_resources
WHERE id in (:ids) WHERE id in (:ids)
""" """,
) )
suspend fun deleteNewsResources(ids: List<String>) suspend fun deleteNewsResources(ids: List<String>)
} }

@ -34,7 +34,7 @@ interface TopicDao {
value = """ value = """
SELECT * FROM topics SELECT * FROM topics
WHERE id = :topicId WHERE id = :topicId
""" """,
) )
fun getTopicEntity(topicId: String): Flow<TopicEntity> fun getTopicEntity(topicId: String): Flow<TopicEntity>
@ -45,7 +45,7 @@ interface TopicDao {
value = """ value = """
SELECT * FROM topics SELECT * FROM topics
WHERE id IN (:ids) WHERE id IN (:ids)
""" """,
) )
fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>>
@ -74,7 +74,7 @@ interface TopicDao {
value = """ value = """
DELETE FROM topics DELETE FROM topics
WHERE id in (:ids) WHERE id in (:ids)
""" """,
) )
suspend fun deleteTopics(ids: List<String>) suspend fun deleteTopics(ids: List<String>)
} }

@ -27,7 +27,7 @@ import kotlinx.datetime.Instant
* Defines an NiA news resource. * Defines an NiA news resource.
*/ */
@Entity( @Entity(
tableName = "news_resources" tableName = "news_resources",
) )
data class NewsResourceEntity( data class NewsResourceEntity(
@PrimaryKey @PrimaryKey
@ -50,5 +50,5 @@ fun NewsResourceEntity.asExternalModel() = NewsResource(
headerImageUrl = headerImageUrl, headerImageUrl = headerImageUrl,
publishDate = publishDate, publishDate = publishDate,
type = type, type = type,
topics = listOf() topics = listOf(),
) )

@ -32,13 +32,13 @@ import androidx.room.Index
entity = NewsResourceEntity::class, entity = NewsResourceEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["news_resource_id"], childColumns = ["news_resource_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
), ),
ForeignKey( ForeignKey(
entity = TopicEntity::class, entity = TopicEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["topic_id"], childColumns = ["topic_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
), ),
], ],
indices = [ indices = [

@ -34,9 +34,9 @@ data class PopulatedNewsResource(
value = NewsResourceTopicCrossRef::class, value = NewsResourceTopicCrossRef::class,
parentColumn = "news_resource_id", parentColumn = "news_resource_id",
entityColumn = "topic_id", entityColumn = "topic_id",
) ),
) )
val topics: List<TopicEntity> val topics: List<TopicEntity>,
) )
fun PopulatedNewsResource.asExternalModel() = NewsResource( fun PopulatedNewsResource.asExternalModel() = NewsResource(
@ -47,5 +47,5 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
headerImageUrl = entity.headerImageUrl, headerImageUrl = entity.headerImageUrl,
publishDate = entity.publishDate, publishDate = entity.publishDate,
type = entity.type, type = entity.type,
topics = topics.map(TopicEntity::asExternalModel) topics = topics.map(TopicEntity::asExternalModel),
) )

@ -25,13 +25,13 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Singleton
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataStoreModule::class] replaces = [DataStoreModule::class],
) )
object TestDataStoreModule { object TestDataStoreModule {
@ -39,13 +39,13 @@ object TestDataStoreModule {
@Singleton @Singleton
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer) tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer)
} }
fun TemporaryFolder.testUserPreferencesDataStore( fun TemporaryFolder.testUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer() userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create( ) = DataStoreFactory.create(
serializer = userPreferencesSerializer, serializer = userPreferencesSerializer,
) { ) {

@ -30,14 +30,14 @@ object IntToStringIdsMigration : DataMigration<UserPreferences> {
// Migrate topic ids // Migrate topic ids
deprecatedFollowedTopicIds.clear() deprecatedFollowedTopicIds.clear()
deprecatedFollowedTopicIds.addAll( deprecatedFollowedTopicIds.addAll(
currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString) currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString),
) )
deprecatedIntFollowedTopicIds.clear() deprecatedIntFollowedTopicIds.clear()
// Migrate author ids // Migrate author ids
deprecatedFollowedAuthorIds.clear() deprecatedFollowedAuthorIds.clear()
deprecatedFollowedAuthorIds.addAll( deprecatedFollowedAuthorIds.addAll(
currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString) currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString),
) )
deprecatedIntFollowedAuthorIds.clear() deprecatedIntFollowedAuthorIds.clear()

@ -30,21 +30,21 @@ object ListToMapMigration : DataMigration<UserPreferences> {
// Migrate topic id lists // Migrate topic id lists
followedTopicIds.clear() followedTopicIds.clear()
followedTopicIds.putAll( followedTopicIds.putAll(
currentData.deprecatedFollowedTopicIdsList.associateWith { true } currentData.deprecatedFollowedTopicIdsList.associateWith { true },
) )
deprecatedFollowedTopicIds.clear() deprecatedFollowedTopicIds.clear()
// Migrate author ids // Migrate author ids
followedAuthorIds.clear() followedAuthorIds.clear()
followedAuthorIds.putAll( followedAuthorIds.putAll(
currentData.deprecatedFollowedAuthorIdsList.associateWith { true } currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
) )
deprecatedFollowedAuthorIds.clear() deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks // Migrate bookmarks
bookmarkedNewsResourceIds.clear() bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.putAll( bookmarkedNewsResourceIds.putAll(
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true } currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
) )
deprecatedBookmarkedNewsResourceIds.clear() deprecatedBookmarkedNewsResourceIds.clear()

@ -21,15 +21,14 @@ import androidx.datastore.core.DataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.IOException
import javax.inject.Inject
class NiaPreferencesDataSource @Inject constructor( class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences> private val userPreferences: DataStore<UserPreferences>,
) { ) {
val userData = userPreferences.data val userData = userPreferences.data
.map { .map {
UserData( UserData(
@ -39,20 +38,23 @@ class NiaPreferencesDataSource @Inject constructor(
null, null,
ThemeBrandProto.THEME_BRAND_UNSPECIFIED, ThemeBrandProto.THEME_BRAND_UNSPECIFIED,
ThemeBrandProto.UNRECOGNIZED, ThemeBrandProto.UNRECOGNIZED,
ThemeBrandProto.THEME_BRAND_DEFAULT -> ThemeBrand.DEFAULT ThemeBrandProto.THEME_BRAND_DEFAULT,
-> ThemeBrand.DEFAULT
ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID
}, },
darkThemeConfig = when (it.darkThemeConfig) { darkThemeConfig = when (it.darkThemeConfig) {
null, null,
DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED, DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,
DarkThemeConfigProto.UNRECOGNIZED, DarkThemeConfigProto.UNRECOGNIZED,
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM -> DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM,
->
DarkThemeConfig.FOLLOW_SYSTEM DarkThemeConfig.FOLLOW_SYSTEM
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
}, },
shouldHideOnboarding = it.shouldHideOnboarding useDynamicColor = it.useDynamicColor,
shouldHideOnboarding = it.shouldHideOnboarding,
) )
} }
@ -98,6 +100,14 @@ class NiaPreferencesDataSource @Inject constructor(
} }
} }
suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
userPreferences.updateData {
it.copy {
this.useDynamicColor = useDynamicColor
}
}
}
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
userPreferences.updateData { userPreferences.updateData {
it.copy { it.copy {
@ -145,8 +155,8 @@ class NiaPreferencesDataSource @Inject constructor(
val updatedChangeListVersions = update( val updatedChangeListVersions = update(
ChangeListVersions( ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion, topicVersion = currentPreferences.topicChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion newsResourceVersion = currentPreferences.newsResourceChangeListVersion,
) ),
) )
currentPreferences.copy { currentPreferences.copy {
@ -169,7 +179,6 @@ class NiaPreferencesDataSource @Inject constructor(
} }
private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() { private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() {
if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) { if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
shouldHideOnboarding = false shouldHideOnboarding = false
} }

@ -30,10 +30,10 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -44,14 +44,14 @@ object DataStoreModule {
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
userPreferencesSerializer: UserPreferencesSerializer userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
DataStoreFactory.create( DataStoreFactory.create(
serializer = userPreferencesSerializer, serializer = userPreferencesSerializer,
scope = CoroutineScope(ioDispatcher + SupervisorJob()), scope = CoroutineScope(ioDispatcher + SupervisorJob()),
migrations = listOf( migrations = listOf(
IntToStringIdsMigration, IntToStringIdsMigration,
) ),
) { ) {
context.dataStoreFile("user_preferences.pb") context.dataStoreFile("user_preferences.pb")
} }

@ -45,4 +45,6 @@ message UserPreferences {
DarkThemeConfigProto dark_theme_config = 17; DarkThemeConfigProto dark_theme_config = 17;
bool should_hide_onboarding = 18; bool should_hide_onboarding = 18;
bool use_dynamic_color = 19;
} }

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** /**
* Unit test for [IntToStringIdsMigration] * Unit test for [IntToStringIdsMigration]
@ -35,7 +35,7 @@ class IntToStringIdsMigrationTest {
// Assert that there are no string topic ids yet // Assert that there are no string topic ids yet
assertEquals( assertEquals(
emptyList<String>(), emptyList<String>(),
preMigrationUserPreferences.deprecatedFollowedTopicIdsList preMigrationUserPreferences.deprecatedFollowedTopicIdsList,
) )
// Run the migration // Run the migration
@ -48,7 +48,7 @@ class IntToStringIdsMigrationTest {
deprecatedFollowedTopicIds.addAll(listOf("1", "2", "3")) deprecatedFollowedTopicIds.addAll(listOf("1", "2", "3"))
hasDoneIntToStringIdMigration = true hasDoneIntToStringIdMigration = true
}, },
postMigrationUserPreferences postMigrationUserPreferences,
) )
// Assert that the migration has been marked complete // Assert that the migration has been marked complete
@ -64,7 +64,7 @@ class IntToStringIdsMigrationTest {
// Assert that there are no string author ids yet // Assert that there are no string author ids yet
assertEquals( assertEquals(
emptyList<String>(), emptyList<String>(),
preMigrationUserPreferences.deprecatedFollowedAuthorIdsList preMigrationUserPreferences.deprecatedFollowedAuthorIdsList,
) )
// Run the migration // Run the migration
@ -77,7 +77,7 @@ class IntToStringIdsMigrationTest {
deprecatedFollowedAuthorIds.addAll(listOf("4", "5", "6")) deprecatedFollowedAuthorIds.addAll(listOf("4", "5", "6"))
hasDoneIntToStringIdMigration = true hasDoneIntToStringIdMigration = true
}, },
postMigrationUserPreferences postMigrationUserPreferences,
) )
// Assert that the migration has been marked complete // Assert that the migration has been marked complete

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ListToMapMigrationTest { class ListToMapMigrationTest {
@ -32,7 +32,7 @@ class ListToMapMigrationTest {
// Assert that there are no topic ids in the map yet // Assert that there are no topic ids in the map yet
assertEquals( assertEquals(
emptyMap<String, Boolean>(), emptyMap<String, Boolean>(),
preMigrationUserPreferences.followedTopicIdsMap preMigrationUserPreferences.followedTopicIdsMap,
) )
// Run the migration // Run the migration
@ -42,7 +42,7 @@ class ListToMapMigrationTest {
// Assert the deprecated topic ids have been migrated to the topic ids map // Assert the deprecated topic ids have been migrated to the topic ids map
assertEquals( assertEquals(
mapOf("1" to true, "2" to true, "3" to true), mapOf("1" to true, "2" to true, "3" to true),
postMigrationUserPreferences.followedTopicIdsMap postMigrationUserPreferences.followedTopicIdsMap,
) )
// Assert that the migration has been marked complete // Assert that the migration has been marked complete
@ -58,7 +58,7 @@ class ListToMapMigrationTest {
// Assert that there are no author ids in the map yet // Assert that there are no author ids in the map yet
assertEquals( assertEquals(
emptyMap<String, Boolean>(), emptyMap<String, Boolean>(),
preMigrationUserPreferences.followedAuthorIdsMap preMigrationUserPreferences.followedAuthorIdsMap,
) )
// Run the migration // Run the migration
@ -68,7 +68,7 @@ class ListToMapMigrationTest {
// Assert the deprecated author ids have been migrated to the author ids map // Assert the deprecated author ids have been migrated to the author ids map
assertEquals( assertEquals(
mapOf("4" to true, "5" to true, "6" to true), mapOf("4" to true, "5" to true, "6" to true),
postMigrationUserPreferences.followedAuthorIdsMap postMigrationUserPreferences.followedAuthorIdsMap,
) )
// Assert that the migration has been marked complete // Assert that the migration has been marked complete
@ -84,7 +84,7 @@ class ListToMapMigrationTest {
// Assert that there are no bookmarks in the map yet // Assert that there are no bookmarks in the map yet
assertEquals( assertEquals(
emptyMap<String, Boolean>(), emptyMap<String, Boolean>(),
preMigrationUserPreferences.bookmarkedNewsResourceIdsMap preMigrationUserPreferences.bookmarkedNewsResourceIdsMap,
) )
// Run the migration // Run the migration
@ -94,7 +94,7 @@ class ListToMapMigrationTest {
// Assert the deprecated bookmarks have been migrated to the bookmarks map // Assert the deprecated bookmarks have been migrated to the bookmarks map
assertEquals( assertEquals(
mapOf("7" to true, "8" to true, "9" to true), mapOf("7" to true, "8" to true, "9" to true),
postMigrationUserPreferences.bookmarkedNewsResourceIdsMap postMigrationUserPreferences.bookmarkedNewsResourceIdsMap,
) )
// Assert that the migration has been marked complete // Assert that the migration has been marked complete

@ -17,14 +17,14 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest { class NiaPreferencesDataSourceTest {
private lateinit var subject: NiaPreferencesDataSource private lateinit var subject: NiaPreferencesDataSource
@ -35,7 +35,7 @@ class NiaPreferencesDataSourceTest {
@Before @Before
fun setup() { fun setup() {
subject = NiaPreferencesDataSource( subject = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(),
) )
} }
@ -52,7 +52,6 @@ class NiaPreferencesDataSourceTest {
@Test @Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single topic. // Given: user completes onboarding by selecting a single topic.
subject.toggleFollowedTopicId("1", true) subject.toggleFollowedTopicId("1", true)
subject.setShouldHideOnboarding(true) subject.setShouldHideOnboarding(true)
@ -66,7 +65,6 @@ class NiaPreferencesDataSourceTest {
@Test @Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several topics. // Given: user completes onboarding by selecting several topics.
subject.setFollowedTopicIds(setOf("1", "2")) subject.setFollowedTopicIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true) subject.setShouldHideOnboarding(true)
@ -77,4 +75,15 @@ class NiaPreferencesDataSourceTest {
// Then: onboarding should be shown again // Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
@Test
fun shouldUseDynamicColorFalseByDefault() = runTest {
assertFalse(subject.userData.first().useDynamicColor)
}
@Test
fun userShouldUseDynamicColorIsTrueWhenSet() = runTest {
subject.setDynamicColorPreference(true)
assertTrue(subject.userData.first().useDynamicColor)
}
} }

@ -17,11 +17,11 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import androidx.datastore.core.CorruptionException import androidx.datastore.core.CorruptionException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserPreferencesSerializerTest { class UserPreferencesSerializerTest {
private val userPreferencesSerializer = UserPreferencesSerializer() private val userPreferencesSerializer = UserPreferencesSerializer()
@ -32,7 +32,7 @@ class UserPreferencesSerializerTest {
userPreferences { userPreferences {
// Default value // Default value
}, },
userPreferencesSerializer.defaultValue userPreferencesSerializer.defaultValue,
) )
} }
@ -53,7 +53,7 @@ class UserPreferencesSerializerTest {
assertEquals( assertEquals(
expectedUserPreferences, expectedUserPreferences,
actualUserPreferences actualUserPreferences,
) )
} }

@ -31,6 +31,7 @@ android {
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt.compose)
api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material.iconsExtended)
@ -41,4 +42,4 @@ dependencies {
api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime)
lintPublish(project(":lint")) lintPublish(project(":lint"))
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
} }

@ -38,10 +38,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightAndroid
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightDefaultColorScheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.LightDefaultColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme 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.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlin.test.assertEquals import com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
/** /**
* Tests [NiaTheme] using different combinations of the theme mode parameters: * Tests [NiaTheme] using different combinations of the theme mode parameters:
@ -62,7 +64,7 @@ class ThemeTest {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
disableDynamicTheming = true, disableDynamicTheming = true,
androidTheme = false androidTheme = false,
) { ) {
val colorScheme = LightDefaultColorScheme val colorScheme = LightDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -70,6 +72,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -80,7 +84,7 @@ class ThemeTest {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
disableDynamicTheming = true, disableDynamicTheming = true,
androidTheme = false androidTheme = false,
) { ) {
val colorScheme = DarkDefaultColorScheme val colorScheme = DarkDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -88,6 +92,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -97,7 +103,8 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
androidTheme = false disableDynamicTheming = false,
androidTheme = false,
) { ) {
val colorScheme = dynamicLightColorSchemeWithFallback() val colorScheme = dynamicLightColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -105,6 +112,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintThemeWithFallback(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -114,7 +123,8 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
androidTheme = false disableDynamicTheming = false,
androidTheme = false,
) { ) {
val colorScheme = dynamicDarkColorSchemeWithFallback() val colorScheme = dynamicDarkColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -122,6 +132,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintThemeWithFallback(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -132,7 +144,7 @@ class ThemeTest {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
disableDynamicTheming = true, disableDynamicTheming = true,
androidTheme = true androidTheme = true,
) { ) {
val colorScheme = LightAndroidColorScheme val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -140,6 +152,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -150,7 +164,7 @@ class ThemeTest {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
disableDynamicTheming = true, disableDynamicTheming = true,
androidTheme = true androidTheme = true,
) { ) {
val colorScheme = DarkAndroidColorScheme val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -158,6 +172,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -167,7 +183,8 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
androidTheme = true disableDynamicTheming = false,
androidTheme = true,
) { ) {
val colorScheme = LightAndroidColorScheme val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -175,6 +192,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -184,7 +203,8 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
androidTheme = true disableDynamicTheming = false,
androidTheme = true,
) { ) {
val colorScheme = DarkAndroidColorScheme val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -192,6 +212,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -222,7 +244,7 @@ class ThemeTest {
return GradientColors( return GradientColors(
top = colorScheme.inverseOnSurface, top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer, bottom = colorScheme.primaryContainer,
container = colorScheme.surface container = colorScheme.surface,
) )
} }
@ -237,16 +259,28 @@ class ThemeTest {
private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme { private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme {
return BackgroundTheme( return BackgroundTheme(
color = colorScheme.surface, color = colorScheme.surface,
tonalElevation = 2.dp tonalElevation = 2.dp,
) )
} }
private fun defaultTintTheme(): TintTheme {
return TintTheme()
}
private fun dynamicTintThemeWithFallback(colorScheme: ColorScheme): TintTheme {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
TintTheme(colorScheme.primary)
} else {
TintTheme()
}
}
/** /**
* Workaround for the fact that the NiA design system specify all color scheme values. * Workaround for the fact that the NiA design system specify all color scheme values.
*/ */
private fun assertColorSchemesEqual( private fun assertColorSchemesEqual(
expectedColorScheme: ColorScheme, expectedColorScheme: ColorScheme,
actualColorScheme: ColorScheme actualColorScheme: ColorScheme,
) { ) {
assertEquals(expectedColorScheme.primary, actualColorScheme.primary) assertEquals(expectedColorScheme.primary, actualColorScheme.primary)
assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary) assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary)
@ -257,7 +291,7 @@ class ThemeTest {
assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer) assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer)
assertEquals( assertEquals(
expectedColorScheme.onSecondaryContainer, expectedColorScheme.onSecondaryContainer,
actualColorScheme.onSecondaryContainer actualColorScheme.onSecondaryContainer,
) )
assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary) assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary)
assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary) assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary)

@ -50,7 +50,7 @@ import kotlin.math.tan
@Composable @Composable
fun NiaBackground( fun NiaBackground(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val color = LocalBackgroundTheme.current.color val color = LocalBackgroundTheme.current.color
val tonalElevation = LocalBackgroundTheme.current.tonalElevation val tonalElevation = LocalBackgroundTheme.current.tonalElevation
@ -77,7 +77,7 @@ fun NiaBackground(
fun NiaGradientBackground( fun NiaGradientBackground(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
gradientColors: GradientColors = LocalGradientColors.current, gradientColors: GradientColors = LocalGradientColors.current,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val currentTopColor by rememberUpdatedState(gradientColors.top) val currentTopColor by rememberUpdatedState(gradientColors.top)
val currentBottomColor by rememberUpdatedState(gradientColors.bottom) val currentBottomColor by rememberUpdatedState(gradientColors.bottom)
@ -87,7 +87,7 @@ fun NiaGradientBackground(
} else { } else {
gradientColors.container gradientColors.container
}, },
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize(),
) { ) {
Box( Box(
Modifier Modifier
@ -98,7 +98,7 @@ fun NiaGradientBackground(
val offset = size.height * tan( val offset = size.height * tan(
Math Math
.toRadians(11.06) .toRadians(11.06)
.toFloat() .toFloat(),
) )
val start = Offset(size.width / 2 + offset / 2, 0f) val start = Offset(size.width / 2 + offset / 2, 0f)
@ -132,7 +132,7 @@ fun NiaGradientBackground(
drawRect(topGradient) drawRect(topGradient)
drawRect(bottomGradient) drawRect(bottomGradient)
} }
} },
) { ) {
content() content()
} }
@ -158,7 +158,7 @@ fun BackgroundDefault() {
@ThemePreviews @ThemePreviews
@Composable @Composable
fun BackgroundDynamic() { fun BackgroundDynamic() {
NiaTheme { NiaTheme(disableDynamicTheming = false) {
NiaBackground(Modifier.size(100.dp), content = {}) NiaBackground(Modifier.size(100.dp), content = {})
} }
} }
@ -182,7 +182,7 @@ fun GradientBackgroundDefault() {
@ThemePreviews @ThemePreviews
@Composable @Composable
fun GradientBackgroundDynamic() { fun GradientBackgroundDynamic() {
NiaTheme { NiaTheme(disableDynamicTheming = false) {
NiaGradientBackground(Modifier.size(100.dp), content = {}) NiaGradientBackground(Modifier.size(100.dp), content = {})
} }
} }

@ -48,17 +48,17 @@ fun NiaButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding, contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit,
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.onBackground containerColor = MaterialTheme.colorScheme.onBackground,
), ),
contentPadding = contentPadding, contentPadding = contentPadding,
content = content content = content,
) )
} }
@ -78,7 +78,7 @@ fun NiaButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
text: @Composable () -> Unit, text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null leadingIcon: @Composable (() -> Unit)? = null,
) { ) {
NiaButton( NiaButton(
onClick = onClick, onClick = onClick,
@ -88,11 +88,11 @@ fun NiaButton(
ButtonDefaults.ButtonWithIconContentPadding ButtonDefaults.ButtonWithIconContentPadding
} else { } else {
ButtonDefaults.ContentPadding ButtonDefaults.ContentPadding
} },
) { ) {
NiaButtonContent( NiaButtonContent(
text = text, text = text,
leadingIcon = leadingIcon leadingIcon = leadingIcon,
) )
} }
} }
@ -114,14 +114,14 @@ fun NiaOutlinedButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding, contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit,
) { ) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground contentColor = MaterialTheme.colorScheme.onBackground,
), ),
border = BorderStroke( border = BorderStroke(
width = NiaButtonDefaults.OutlinedButtonBorderWidth, width = NiaButtonDefaults.OutlinedButtonBorderWidth,
@ -129,12 +129,12 @@ fun NiaOutlinedButton(
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
} else { } else {
MaterialTheme.colorScheme.onSurface.copy( MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha,
) )
} },
), ),
contentPadding = contentPadding, contentPadding = contentPadding,
content = content content = content,
) )
} }
@ -154,7 +154,7 @@ fun NiaOutlinedButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
text: @Composable () -> Unit, text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null leadingIcon: @Composable (() -> Unit)? = null,
) { ) {
NiaOutlinedButton( NiaOutlinedButton(
onClick = onClick, onClick = onClick,
@ -164,11 +164,11 @@ fun NiaOutlinedButton(
ButtonDefaults.ButtonWithIconContentPadding ButtonDefaults.ButtonWithIconContentPadding
} else { } else {
ButtonDefaults.ContentPadding ButtonDefaults.ContentPadding
} },
) { ) {
NiaButtonContent( NiaButtonContent(
text = text, text = text,
leadingIcon = leadingIcon leadingIcon = leadingIcon,
) )
} }
} }
@ -187,16 +187,16 @@ fun NiaTextButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit,
) { ) {
TextButton( TextButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground contentColor = MaterialTheme.colorScheme.onBackground,
), ),
content = content content = content,
) )
} }
@ -216,16 +216,16 @@ fun NiaTextButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
text: @Composable () -> Unit, text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null leadingIcon: @Composable (() -> Unit)? = null,
) { ) {
NiaTextButton( NiaTextButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
enabled = enabled enabled = enabled,
) { ) {
NiaButtonContent( NiaButtonContent(
text = text, text = text,
leadingIcon = leadingIcon leadingIcon = leadingIcon,
) )
} }
} }
@ -239,7 +239,7 @@ fun NiaTextButton(
@Composable @Composable
private fun NiaButtonContent( private fun NiaButtonContent(
text: @Composable () -> Unit, text: @Composable () -> Unit,
leadingIcon: @Composable (() -> Unit)? = null leadingIcon: @Composable (() -> Unit)? = null,
) { ) {
if (leadingIcon != null) { if (leadingIcon != null) {
Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) { Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) {
@ -253,8 +253,8 @@ private fun NiaButtonContent(
ButtonDefaults.IconSpacing ButtonDefaults.IconSpacing
} else { } else {
0.dp 0.dp
} },
) ),
) { ) {
text() text()
} }
@ -267,6 +267,7 @@ object NiaButtonDefaults {
// TODO: File bug // TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default // OutlinedButton border color doesn't respect disabled state by default
const val DisabledOutlinedButtonBorderAlpha = 0.12f const val DisabledOutlinedButtonBorderAlpha = 0.12f
// TODO: File bug // TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults // OutlinedButton default border width isn't exposed via ButtonDefaults
val OutlinedButtonBorderWidth = 1.dp val OutlinedButtonBorderWidth = 1.dp

@ -46,7 +46,7 @@ fun NiaFilterChip(
onSelectedChange: (Boolean) -> Unit, onSelectedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
label: @Composable () -> Unit label: @Composable () -> Unit,
) { ) {
FilterChip( FilterChip(
selected = selected, selected = selected,
@ -62,7 +62,7 @@ fun NiaFilterChip(
{ {
Icon( Icon(
imageVector = NiaIcons.Check, imageVector = NiaIcons.Check,
contentDescription = null contentDescription = null,
) )
} }
} else { } else {
@ -73,33 +73,33 @@ fun NiaFilterChip(
borderColor = MaterialTheme.colorScheme.onBackground, borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground, selectedBorderColor = MaterialTheme.colorScheme.onBackground,
disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy( disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha alpha = NiaChipDefaults.DisabledChipContentAlpha,
), ),
disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy( disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha alpha = NiaChipDefaults.DisabledChipContentAlpha,
), ),
selectedBorderWidth = NiaChipDefaults.ChipBorderWidth selectedBorderWidth = NiaChipDefaults.ChipBorderWidth,
), ),
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
labelColor = MaterialTheme.colorScheme.onBackground, labelColor = MaterialTheme.colorScheme.onBackground,
iconColor = MaterialTheme.colorScheme.onBackground, iconColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = if (selected) { disabledContainerColor = if (selected) {
MaterialTheme.colorScheme.onBackground.copy( MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContainerAlpha alpha = NiaChipDefaults.DisabledChipContainerAlpha,
) )
} else { } else {
Color.Transparent Color.Transparent
}, },
disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy( disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha alpha = NiaChipDefaults.DisabledChipContentAlpha,
), ),
disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy( disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha alpha = NiaChipDefaults.DisabledChipContentAlpha,
), ),
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onBackground, selectedLabelColor = MaterialTheme.colorScheme.onBackground,
selectedLeadingIconColor = MaterialTheme.colorScheme.onBackground selectedLeadingIconColor = MaterialTheme.colorScheme.onBackground,
) ),
) )
} }

@ -61,7 +61,7 @@ fun <T> NiaDropdownMenuButton(
text: @Composable () -> Unit, text: @Composable () -> Unit,
itemText: @Composable (item: T) -> Unit, itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null, itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -69,7 +69,7 @@ fun <T> NiaDropdownMenuButton(
onClick = { expanded = true }, onClick = { expanded = true },
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground contentColor = MaterialTheme.colorScheme.onBackground,
), ),
border = BorderStroke( border = BorderStroke(
width = NiaDropdownMenuDefaults.DropdownMenuButtonBorderWidth, width = NiaDropdownMenuDefaults.DropdownMenuButtonBorderWidth,
@ -77,11 +77,11 @@ fun <T> NiaDropdownMenuButton(
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
} else { } else {
MaterialTheme.colorScheme.onSurface.copy( MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaDropdownMenuDefaults.DisabledDropdownMenuButtonBorderAlpha alpha = NiaDropdownMenuDefaults.DisabledDropdownMenuButtonBorderAlpha,
) )
} },
), ),
contentPadding = NiaDropdownMenuDefaults.DropdownMenuButtonContentPadding contentPadding = NiaDropdownMenuDefaults.DropdownMenuButtonContentPadding,
) { ) {
NiaDropdownMenuButtonContent( NiaDropdownMenuButtonContent(
text = text, text = text,
@ -92,9 +92,9 @@ fun <T> NiaDropdownMenuButton(
} else { } else {
NiaIcons.ArrowDropDown NiaIcons.ArrowDropDown
}, },
contentDescription = null contentDescription = null,
) )
} },
) )
} }
NiaDropdownMenu( NiaDropdownMenu(
@ -105,7 +105,7 @@ fun <T> NiaDropdownMenuButton(
dismissOnItemClick = dismissOnItemClick, dismissOnItemClick = dismissOnItemClick,
itemText = itemText, itemText = itemText,
itemLeadingIcon = itemLeadingIcon, itemLeadingIcon = itemLeadingIcon,
itemTrailingIcon = itemTrailingIcon itemTrailingIcon = itemTrailingIcon,
) )
} }
} }
@ -129,8 +129,8 @@ private fun NiaDropdownMenuButtonContent(
ButtonDefaults.IconSpacing ButtonDefaults.IconSpacing
} else { } else {
0.dp 0.dp
} },
) ),
) { ) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) { ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text() text()
@ -166,11 +166,11 @@ fun <T> NiaDropdownMenu(
dismissOnItemClick: Boolean = true, dismissOnItemClick: Boolean = true,
itemText: @Composable (item: T) -> Unit, itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null, itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) { ) {
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismissRequest onDismissRequest = onDismissRequest,
) { ) {
items.forEach { item -> items.forEach { item ->
DropdownMenuItem( DropdownMenuItem(
@ -188,7 +188,7 @@ fun <T> NiaDropdownMenu(
{ itemTrailingIcon(item) } { itemTrailingIcon(item) }
} else { } else {
null null
} },
) )
} }
} }
@ -201,9 +201,11 @@ object NiaDropdownMenuDefaults {
// TODO: File bug // TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default // OutlinedButton border color doesn't respect disabled state by default
const val DisabledDropdownMenuButtonBorderAlpha = 0.12f const val DisabledDropdownMenuButtonBorderAlpha = 0.12f
// TODO: File bug // TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults // OutlinedButton default border width isn't exposed via ButtonDefaults
val DropdownMenuButtonBorderWidth = 1.dp val DropdownMenuButtonBorderWidth = 1.dp
// TODO: File bug // TODO: File bug
// Various default button padding values aren't exposed via ButtonDefaults // Various default button padding values aren't exposed via ButtonDefaults
val DropdownMenuButtonContentPadding = val DropdownMenuButtonContentPadding =
@ -211,6 +213,6 @@ object NiaDropdownMenuDefaults {
start = 24.dp, start = 24.dp,
top = 8.dp, top = 8.dp,
end = 16.dp, end = 16.dp,
bottom = 8.dp bottom = 8.dp,
) )
} }

@ -0,0 +1,44 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
/**
* A wrapper around [AsyncImage] which determines the colorFilter based on the theme
*/
@Composable
fun DynamicAsyncImage(
imageUrl: String,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
) {
val iconTint = LocalTintTheme.current.iconTint
AsyncImage(
placeholder = placeholder,
model = imageUrl,
contentDescription = contentDescription,
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null,
modifier = modifier,
)
}

@ -43,7 +43,7 @@ fun NiaIconToggleButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
checkedIcon: @Composable () -> Unit = icon checkedIcon: @Composable () -> Unit = icon,
) { ) {
// TODO: File bug // TODO: File bug
// Can't use regular IconToggleButton as it doesn't include a shape (appears square) // Can't use regular IconToggleButton as it doesn't include a shape (appears square)
@ -57,12 +57,12 @@ fun NiaIconToggleButton(
checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = if (checked) { disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy( MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha,
) )
} else { } else {
Color.Transparent Color.Transparent
} },
) ),
) { ) {
if (checked) checkedIcon() else icon() if (checked) checkedIcon() else icon()
} }

@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -52,7 +53,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun NiaLoadingWheel( fun NiaLoadingWheel(
contentDesc: String, contentDesc: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
@ -67,8 +68,8 @@ fun NiaLoadingWheel(
animationSpec = tween( animationSpec = tween(
durationMillis = 100, durationMillis = 100,
easing = FastOutSlowInEasing, easing = FastOutSlowInEasing,
delayMillis = 40 * index delayMillis = 40 * index,
) ),
) )
} }
} }
@ -79,8 +80,8 @@ fun NiaLoadingWheel(
initialValue = 0F, initialValue = 0F,
targetValue = 360F, targetValue = 360F,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing) animation = tween(durationMillis = ROTATION_TIME, easing = LinearEasing),
) ),
) )
// Specifies the color animation for the base-to-progress line color change // Specifies the color animation for the base-to-progress line color change
@ -97,8 +98,8 @@ fun NiaLoadingWheel(
baseLineColor at ROTATION_TIME / NUM_OF_LINES with LinearEasing baseLineColor at ROTATION_TIME / NUM_OF_LINES with LinearEasing
}, },
repeatMode = RepeatMode.Restart, repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index) initialStartOffset = StartOffset(ROTATION_TIME / NUM_OF_LINES / 2 * index),
) ),
) )
} }
@ -109,6 +110,7 @@ fun NiaLoadingWheel(
.padding(8.dp) .padding(8.dp)
.graphicsLayer { rotationZ = rotationAnim } .graphicsLayer { rotationZ = rotationAnim }
.semantics { contentDescription = contentDesc } .semantics { contentDescription = contentDesc }
.testTag("loadingWheel"),
) { ) {
repeat(NUM_OF_LINES) { index -> repeat(NUM_OF_LINES) { index ->
rotate(degrees = index * 30f) { rotate(degrees = index * 30f) {
@ -119,7 +121,7 @@ fun NiaLoadingWheel(
strokeWidth = 4F, strokeWidth = 4F,
cap = StrokeCap.Round, cap = StrokeCap.Round,
start = Offset(size.width / 2, size.height / 4), start = Offset(size.width / 2, size.height / 4),
end = Offset(size.width / 2, floatAnimValues[index].value * size.height / 4) end = Offset(size.width / 2, floatAnimValues[index].value * size.height / 4),
) )
} }
} }
@ -129,7 +131,7 @@ fun NiaLoadingWheel(
@Composable @Composable
fun NiaOverlayLoadingWheel( fun NiaOverlayLoadingWheel(
contentDesc: String, contentDesc: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
Surface( Surface(
shape = RoundedCornerShape(60.dp), shape = RoundedCornerShape(60.dp),

@ -54,7 +54,7 @@ fun RowScope.NiaNavigationBarItem(
selectedIcon: @Composable () -> Unit = icon, selectedIcon: @Composable () -> Unit = icon,
enabled: Boolean = true, enabled: Boolean = true,
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true alwaysShowLabel: Boolean = true,
) { ) {
NavigationBarItem( NavigationBarItem(
selected = selected, selected = selected,
@ -69,8 +69,8 @@ fun RowScope.NiaNavigationBarItem(
unselectedIconColor = NiaNavigationDefaults.navigationContentColor(), unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),
selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(), selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = NiaNavigationDefaults.navigationContentColor(), unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),
indicatorColor = NiaNavigationDefaults.navigationIndicatorColor() indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),
) ),
) )
} }
@ -84,13 +84,13 @@ fun RowScope.NiaNavigationBarItem(
@Composable @Composable
fun NiaNavigationBar( fun NiaNavigationBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit,
) { ) {
NavigationBar( NavigationBar(
modifier = modifier, modifier = modifier,
contentColor = NiaNavigationDefaults.navigationContentColor(), contentColor = NiaNavigationDefaults.navigationContentColor(),
tonalElevation = 0.dp, tonalElevation = 0.dp,
content = content content = content,
) )
} }
@ -118,7 +118,7 @@ fun NiaNavigationRailItem(
selectedIcon: @Composable () -> Unit = icon, selectedIcon: @Composable () -> Unit = icon,
enabled: Boolean = true, enabled: Boolean = true,
label: @Composable (() -> Unit)? = null, label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true alwaysShowLabel: Boolean = true,
) { ) {
NavigationRailItem( NavigationRailItem(
selected = selected, selected = selected,
@ -133,8 +133,8 @@ fun NiaNavigationRailItem(
unselectedIconColor = NiaNavigationDefaults.navigationContentColor(), unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),
selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(), selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = NiaNavigationDefaults.navigationContentColor(), unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),
indicatorColor = NiaNavigationDefaults.navigationIndicatorColor() indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),
) ),
) )
} }
@ -150,14 +150,14 @@ fun NiaNavigationRailItem(
fun NiaNavigationRail( fun NiaNavigationRail(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
header: @Composable (ColumnScope.() -> Unit)? = null, header: @Composable (ColumnScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit,
) { ) {
NavigationRail( NavigationRail(
modifier = modifier, modifier = modifier,
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = NiaNavigationDefaults.navigationContentColor(), contentColor = NiaNavigationDefaults.navigationContentColor(),
header = header, header = header,
content = content content = content,
) )
} }
@ -167,8 +167,10 @@ fun NiaNavigationRail(
object NiaNavigationDefaults { object NiaNavigationDefaults {
@Composable @Composable
fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant
@Composable @Composable
fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer fun navigationSelectedItemColor() = MaterialTheme.colorScheme.onPrimaryContainer
@Composable @Composable
fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer fun navigationIndicatorColor() = MaterialTheme.colorScheme.primaryContainer
} }

@ -46,7 +46,7 @@ fun NiaTab(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
text: @Composable () -> Unit text: @Composable () -> Unit,
) { ) {
Tab( Tab(
selected = selected, selected = selected,
@ -61,9 +61,9 @@ fun NiaTab(
Box(modifier = Modifier.padding(top = NiaTabDefaults.TabTopPadding)) { Box(modifier = Modifier.padding(top = NiaTabDefaults.TabTopPadding)) {
text() text()
} }
} },
) )
} },
) )
} }
@ -79,7 +79,7 @@ fun NiaTab(
fun NiaTabRow( fun NiaTabRow(
selectedTabIndex: Int, selectedTabIndex: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
tabs: @Composable () -> Unit tabs: @Composable () -> Unit,
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
@ -90,10 +90,10 @@ fun NiaTabRow(
TabRowDefaults.Indicator( TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
height = 2.dp, height = 2.dp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
}, },
tabs = tabs tabs = tabs,
) )
} }

@ -41,15 +41,14 @@ fun NiaTopicTag(
text: @Composable () -> Unit, text: @Composable () -> Unit,
followText: @Composable () -> Unit = { Text(stringResource(R.string.follow)) }, followText: @Composable () -> Unit = { Text(stringResource(R.string.follow)) },
unFollowText: @Composable () -> Unit = { Text(stringResource(R.string.unfollow)) }, unFollowText: @Composable () -> Unit = { Text(stringResource(R.string.unfollow)) },
browseText: @Composable () -> Unit = { Text(stringResource(R.string.browse_topic)) } browseText: @Composable () -> Unit = { Text(stringResource(R.string.browse_topic)) },
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
val containerColor = if (followed) { val containerColor = if (followed) {
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
} else { } else {
MaterialTheme.colorScheme.surfaceVariant.copy( MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha,
) )
} }
TextButton( TextButton(
@ -59,9 +58,9 @@ fun NiaTopicTag(
containerColor = containerColor, containerColor = containerColor,
contentColor = contentColorFor(backgroundColor = containerColor), contentColor = contentColorFor(backgroundColor = containerColor),
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy( disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaTagDefaults.DisabledTopicTagContainerAlpha alpha = NiaTagDefaults.DisabledTopicTagContainerAlpha,
) ),
) ),
) { ) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) { ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text() text()
@ -84,7 +83,7 @@ fun NiaTopicTag(
UNFOLLOW -> unFollowText() UNFOLLOW -> unFollowText()
BROWSE -> browseText() BROWSE -> browseText()
} }
} },
) )
} }
} }
@ -94,6 +93,7 @@ fun NiaTopicTag(
*/ */
object NiaTagDefaults { object NiaTagDefaults {
const val UnfollowedTopicTagContainerAlpha = 0.5f const val UnfollowedTopicTagContainerAlpha = 0.5f
// TODO: File bug // TODO: File bug
// Button disabled container alpha value not exposed by ButtonDefaults // Button disabled container alpha value not exposed by ButtonDefaults
const val DisabledTopicTagContainerAlpha = 0.12f const val DisabledTopicTagContainerAlpha = 0.12f

@ -30,6 +30,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -45,7 +46,7 @@ fun NiaTopAppBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onNavigationClick: () -> Unit = {}, onNavigationClick: () -> Unit = {},
onActionClick: () -> Unit = {} onActionClick: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { Text(text = stringResource(id = titleRes)) }, title = { Text(text = stringResource(id = titleRes)) },
@ -54,7 +55,7 @@ fun NiaTopAppBar(
Icon( Icon(
imageVector = navigationIcon, imageVector = navigationIcon,
contentDescription = navigationIconContentDescription, contentDescription = navigationIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface tint = MaterialTheme.colorScheme.onSurface,
) )
} }
}, },
@ -63,12 +64,12 @@ fun NiaTopAppBar(
Icon( Icon(
imageVector = actionIcon, imageVector = actionIcon,
contentDescription = actionIconContentDescription, contentDescription = actionIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface tint = MaterialTheme.colorScheme.onSurface,
) )
} }
}, },
colors = colors, colors = colors,
modifier = modifier modifier = modifier.testTag("niaTopAppBar"),
) )
} }
@ -83,7 +84,7 @@ fun NiaTopAppBar(
actionIconContentDescription: String?, actionIconContentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onActionClick: () -> Unit = {} onActionClick: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { Text(text = stringResource(id = titleRes)) }, title = { Text(text = stringResource(id = titleRes)) },
@ -92,12 +93,12 @@ fun NiaTopAppBar(
Icon( Icon(
imageVector = actionIcon, imageVector = actionIcon,
contentDescription = actionIconContentDescription, contentDescription = actionIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface tint = MaterialTheme.colorScheme.onSurface,
) )
} }
}, },
colors = colors, colors = colors,
modifier = modifier, modifier = modifier.testTag("niaTopAppBar"),
) )
} }
@ -110,6 +111,6 @@ private fun NiaTopAppBarPreview() {
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
navigationIconContentDescription = "Navigation icon", navigationIconContentDescription = "Navigation icon",
actionIcon = NiaIcons.MoreVert, actionIcon = NiaIcons.MoreVert,
actionIconContentDescription = "Action icon" actionIconContentDescription = "Action icon",
) )
} }

@ -49,25 +49,25 @@ fun NiaViewToggleButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
compactText: @Composable () -> Unit, compactText: @Composable () -> Unit,
expandedText: @Composable () -> Unit expandedText: @Composable () -> Unit,
) { ) {
TextButton( TextButton(
onClick = { onExpandedChange(!expanded) }, onClick = { onExpandedChange(!expanded) },
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground contentColor = MaterialTheme.colorScheme.onBackground,
), ),
contentPadding = NiaViewToggleDefaults.ViewToggleButtonContentPadding contentPadding = NiaViewToggleDefaults.ViewToggleButtonContentPadding,
) { ) {
NiaViewToggleButtonContent( NiaViewToggleButtonContent(
text = if (expanded) expandedText else compactText, text = if (expanded) expandedText else compactText,
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = if (expanded) NiaIcons.ViewDay else NiaIcons.ShortText, imageVector = if (expanded) NiaIcons.ViewDay else NiaIcons.ShortText,
contentDescription = null contentDescription = null,
) )
} },
) )
} }
} }
@ -91,8 +91,8 @@ private fun NiaViewToggleButtonContent(
ButtonDefaults.IconSpacing ButtonDefaults.IconSpacing
} else { } else {
0.dp 0.dp
} },
) ),
) { ) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) { ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text() text()
@ -116,6 +116,6 @@ object NiaViewToggleDefaults {
start = 16.dp, start = 16.dp,
top = 8.dp, top = 8.dp,
end = 12.dp, end = 12.dp,
bottom = 8.dp bottom = 8.dp,
) )
} }

@ -27,7 +27,7 @@ import androidx.compose.ui.unit.Dp
@Immutable @Immutable
data class BackgroundTheme( data class BackgroundTheme(
val color: Color = Color.Unspecified, val color: Color = Color.Unspecified,
val tonalElevation: Dp = Dp.Unspecified val tonalElevation: Dp = Dp.Unspecified,
) )
/** /**

@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.Color
data class GradientColors( data class GradientColors(
val top: Color = Color.Unspecified, val top: Color = Color.Unspecified,
val bottom: Color = Color.Unspecified, val bottom: Color = Color.Unspecified,
val container: Color = Color.Unspecified val container: Color = Color.Unspecified,
) )
/** /**

@ -61,7 +61,7 @@ val LightDefaultColorScheme = lightColorScheme(
onSurfaceVariant = PurpleGray30, onSurfaceVariant = PurpleGray30,
inverseSurface = DarkPurpleGray20, inverseSurface = DarkPurpleGray20,
inverseOnSurface = DarkPurpleGray95, inverseOnSurface = DarkPurpleGray95,
outline = PurpleGray50 outline = PurpleGray50,
) )
/** /**
@ -93,7 +93,7 @@ val DarkDefaultColorScheme = darkColorScheme(
onSurfaceVariant = PurpleGray80, onSurfaceVariant = PurpleGray80,
inverseSurface = DarkPurpleGray90, inverseSurface = DarkPurpleGray90,
inverseOnSurface = DarkPurpleGray10, inverseOnSurface = DarkPurpleGray10,
outline = PurpleGray60 outline = PurpleGray60,
) )
/** /**
@ -125,7 +125,7 @@ val LightAndroidColorScheme = lightColorScheme(
onSurfaceVariant = GreenGray30, onSurfaceVariant = GreenGray30,
inverseSurface = DarkGreenGray20, inverseSurface = DarkGreenGray20,
inverseOnSurface = DarkGreenGray95, inverseOnSurface = DarkGreenGray95,
outline = GreenGray50 outline = GreenGray50,
) )
/** /**
@ -157,7 +157,7 @@ val DarkAndroidColorScheme = darkColorScheme(
onSurfaceVariant = GreenGray80, onSurfaceVariant = GreenGray80,
inverseSurface = DarkGreenGray90, inverseSurface = DarkGreenGray90,
inverseOnSurface = DarkGreenGray10, inverseOnSurface = DarkGreenGray10,
outline = GreenGray60 outline = GreenGray60,
) )
/** /**
@ -185,36 +185,16 @@ val DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black)
* *
* @param darkTheme Whether the theme should use a dark color scheme (follows system by default). * @param darkTheme Whether the theme should use a dark color scheme (follows system by default).
* @param androidTheme Whether the theme should use the Android theme color scheme instead of the * @param androidTheme Whether the theme should use the Android theme color scheme instead of the
* default theme. If this is `false`, then dynamic theming will be used when supported.
*/
@Composable
fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false,
content: @Composable () -> Unit
) = NiaTheme(
darkTheme = darkTheme,
androidTheme = androidTheme,
disableDynamicTheming = false,
content = content
)
/**
* Now in Android theme. This is an internal only version, to allow disabling dynamic theming
* in tests.
*
* @param darkTheme Whether the theme should use a dark color scheme (follows system by default).
* @param androidTheme Whether the theme should use the Android theme color scheme instead of the
* default theme. * default theme.
* @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is
* supported. This parameter has no effect if [androidTheme] is `true`. * supported. This parameter has no effect if [androidTheme] is `true`.
*/ */
@Composable @Composable
internal fun NiaTheme( fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false, androidTheme: Boolean = false,
disableDynamicTheming: Boolean, disableDynamicTheming: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
// Color scheme // Color scheme
val colorScheme = when { val colorScheme = when {
@ -223,6 +203,7 @@ internal fun NiaTheme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
} }
// Gradient colors // Gradient colors
@ -230,7 +211,7 @@ internal fun NiaTheme(
val defaultGradientColors = GradientColors( val defaultGradientColors = GradientColors(
top = colorScheme.inverseOnSurface, top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer, bottom = colorScheme.primaryContainer,
container = colorScheme.surface container = colorScheme.surface,
) )
val gradientColors = when { val gradientColors = when {
androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors
@ -240,24 +221,30 @@ internal fun NiaTheme(
// Background theme // Background theme
val defaultBackgroundTheme = BackgroundTheme( val defaultBackgroundTheme = BackgroundTheme(
color = colorScheme.surface, color = colorScheme.surface,
tonalElevation = 2.dp tonalElevation = 2.dp,
) )
val backgroundTheme = when { val backgroundTheme = when {
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme else -> defaultBackgroundTheme
} }
val tintTheme = when {
androidTheme -> TintTheme()
!disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary)
else -> TintTheme()
}
// Composition locals // Composition locals
CompositionLocalProvider( CompositionLocalProvider(
LocalGradientColors provides gradientColors, LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme LocalBackgroundTheme provides backgroundTheme,
LocalTintTheme provides tintTheme,
) { ) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = NiaTypography, typography = NiaTypography,
content = content content = content,
) )
} }
} }
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

@ -0,0 +1,34 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.designsystem.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* A class to model background color and tonal elevation values for Now in Android.
*/
@Immutable
data class TintTheme(
val iconTint: Color? = null,
)
/**
* A composition local for [TintTheme].
*/
val LocalTintTheme = staticCompositionLocalOf { TintTheme() }

@ -29,90 +29,90 @@ internal val NiaTypography = Typography(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 57.sp, fontSize = 57.sp,
lineHeight = 64.sp, lineHeight = 64.sp,
letterSpacing = (-0.25).sp letterSpacing = (-0.25).sp,
), ),
displayMedium = TextStyle( displayMedium = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 45.sp, fontSize = 45.sp,
lineHeight = 52.sp, lineHeight = 52.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
displaySmall = TextStyle( displaySmall = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 36.sp, fontSize = 36.sp,
lineHeight = 44.sp, lineHeight = 44.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
headlineLarge = TextStyle( headlineLarge = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 40.sp, lineHeight = 40.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
headlineMedium = TextStyle( headlineMedium = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 28.sp, fontSize = 28.sp,
lineHeight = 36.sp, lineHeight = 36.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
headlineSmall = TextStyle( headlineSmall = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 24.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
titleLarge = TextStyle( titleLarge = TextStyle(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 22.sp, fontSize = 22.sp,
lineHeight = 28.sp, lineHeight = 28.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
), ),
titleMedium = TextStyle( titleMedium = TextStyle(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 18.sp, fontSize = 18.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.1.sp letterSpacing = 0.1.sp,
), ),
titleSmall = TextStyle( titleSmall = TextStyle(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp letterSpacing = 0.1.sp,
), ),
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
), ),
bodyMedium = TextStyle( bodyMedium = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.25.sp letterSpacing = 0.25.sp,
), ),
bodySmall = TextStyle( bodySmall = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.4.sp letterSpacing = 0.4.sp,
), ),
labelLarge = TextStyle( labelLarge = TextStyle(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp letterSpacing = 0.1.sp,
), ),
labelMedium = TextStyle( labelMedium = TextStyle(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
), ),
labelSmall = TextStyle( labelSmall = TextStyle(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 10.sp, fontSize = 10.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.sp letterSpacing = 0.sp,
) ),
) )

@ -21,16 +21,16 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/** /**
* A use case which obtains a list of topics with their followed state. * A use case which obtains a list of topics with their followed state.
*/ */
class GetFollowableTopicsUseCase @Inject constructor( class GetFollowableTopicsUseCase @Inject constructor(
private val topicsRepository: TopicsRepository, private val topicsRepository: TopicsRepository,
private val userDataRepository: UserDataRepository private val userDataRepository: UserDataRepository,
) { ) {
/** /**
* Returns a list of topics with their associated followed state. * Returns a list of topics with their associated followed state.
@ -40,13 +40,13 @@ class GetFollowableTopicsUseCase @Inject constructor(
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> { operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> {
return combine( return combine(
userDataRepository.userData, userDataRepository.userData,
topicsRepository.getTopics() topicsRepository.getTopics(),
) { userData, topics -> ) { userData, topics ->
val followedTopics = topics val followedTopics = topics
.map { topic -> .map { topic ->
FollowableTopic( FollowableTopic(
topic = topic, topic = topic,
isFollowed = topic.id in userData.followedTopics isFollowed = topic.id in userData.followedTopics,
) )
} }
when (sortBy) { when (sortBy) {

@ -22,10 +22,10 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import javax.inject.Inject
/** /**
* A use case responsible for obtaining news resources with their associated bookmarked (also known * A use case responsible for obtaining news resources with their associated bookmarked (also known
@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.filterNot
*/ */
class GetUserNewsResourcesUseCase @Inject constructor( class GetUserNewsResourcesUseCase @Inject constructor(
private val newsRepository: NewsRepository, private val newsRepository: NewsRepository,
private val userDataRepository: UserDataRepository private val userDataRepository: UserDataRepository,
) { ) {
/** /**
* Returns a list of UserNewsResources which match the supplied set of topic ids. * Returns a list of UserNewsResources which match the supplied set of topic ids.
@ -42,7 +42,7 @@ class GetUserNewsResourcesUseCase @Inject constructor(
* this is empty the list of news resources will not be filtered. * this is empty the list of news resources will not be filtered.
*/ */
operator fun invoke( operator fun invoke(
filterTopicIds: Set<String> = emptySet() filterTopicIds: Set<String> = emptySet(),
): Flow<List<UserNewsResource>> = ): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) { if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources() newsRepository.getNewsResources()
@ -52,7 +52,7 @@ class GetUserNewsResourcesUseCase @Inject constructor(
} }
private fun Flow<List<NewsResource>>.mapToUserNewsResources( private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData> userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> = ): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() } filterNot { it.isEmpty() }
.combine(userDataStream) { newsResources, userData -> .combine(userDataStream) { newsResources, userData ->

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

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

@ -22,11 +22,11 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class GetFollowableTopicsUseCaseTest { class GetFollowableTopicsUseCaseTest {
@ -38,12 +38,11 @@ class GetFollowableTopicsUseCaseTest {
val useCase = GetFollowableTopicsUseCase( val useCase = GetFollowableTopicsUseCase(
topicsRepository, topicsRepository,
userDataRepository userDataRepository,
) )
@Test @Test
fun whenNoParams_followableTopicsAreReturnedWithNoSorting() = runTest { fun whenNoParams_followableTopicsAreReturnedWithNoSorting() = runTest {
// Obtain a stream of followable topics. // Obtain a stream of followable topics.
val followableTopics = useCase() val followableTopics = useCase()
@ -58,16 +57,15 @@ class GetFollowableTopicsUseCaseTest {
FollowableTopic(testTopics[1], false), FollowableTopic(testTopics[1], false),
FollowableTopic(testTopics[2], true), FollowableTopic(testTopics[2], true),
), ),
followableTopics.first() followableTopics.first(),
) )
} }
@Test @Test
fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest { fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest {
// Obtain a stream of followable topics, sorted by name. // Obtain a stream of followable topics, sorted by name.
val followableTopics = useCase( val followableTopics = useCase(
sortBy = NAME sortBy = NAME,
) )
// Send some test topics and their followed state. // Send some test topics and their followed state.
@ -81,7 +79,7 @@ class GetFollowableTopicsUseCaseTest {
.sortedBy { it.name } .sortedBy { it.name }
.map { .map {
FollowableTopic(it, false) FollowableTopic(it, false)
} },
) )
} }
} }

@ -24,12 +24,12 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class GetUserNewsResourcesUseCaseTest { class GetUserNewsResourcesUseCaseTest {
@ -43,7 +43,6 @@ class GetUserNewsResourcesUseCaseTest {
@Test @Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest { fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream. // Obtain the user news resources stream.
val userNewsResources = useCase() val userNewsResources = useCase()
@ -53,7 +52,7 @@ class GetUserNewsResourcesUseCaseTest {
// Construct the test user data with bookmarks and followed topics. // Construct the test user data with bookmarks and followed topics.
val userData = emptyUserData.copy( val userData = emptyUserData.copy(
bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id), bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
followedTopics = setOf(sampleTopic1.id) followedTopics = setOf(sampleTopic1.id),
) )
userDataRepository.setUserData(userData) userDataRepository.setUserData(userData)
@ -61,13 +60,12 @@ class GetUserNewsResourcesUseCaseTest {
// Check that the correct news resources are returned with their bookmarked state. // Check that the correct news resources are returned with their bookmarked state.
assertEquals( assertEquals(
sampleNewsResources.mapToUserNewsResources(userData), sampleNewsResources.mapToUserNewsResources(userData),
userNewsResources.first() userNewsResources.first(),
) )
} }
@Test @Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id. // Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id)) val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
@ -80,7 +78,7 @@ class GetUserNewsResourcesUseCaseTest {
sampleNewsResources sampleNewsResources
.filter { it.topics.contains(sampleTopic1) } .filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(emptyUserData), .mapToUserNewsResources(emptyUserData),
userNewsResources.first() userNewsResources.first(),
) )
} }
} }
@ -115,7 +113,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic1) topics = listOf(sampleTopic1),
), ),
NewsResource( NewsResource(
id = "2", id = "2",
@ -127,7 +125,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic1, sampleTopic2) topics = listOf(sampleTopic1, sampleTopic2),
), ),
NewsResource( NewsResource(
id = "3", id = "3",
@ -137,6 +135,6 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"), publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic2) topics = listOf(sampleTopic2),
), ),
) )

@ -38,7 +38,6 @@ class UserNewsResourceTest {
*/ */
@Test @Test
fun userNewsResourcesAreConstructedFromNewsResourcesAndUserData() { fun userNewsResourcesAreConstructedFromNewsResourcesAndUserData() {
val newsResource1 = NewsResource( val newsResource1 = NewsResource(
id = "N1", id = "N1",
title = "Test news title", title = "Test news title",
@ -54,7 +53,7 @@ class UserNewsResourceTest {
shortDescription = "Topic 1 short description", shortDescription = "Topic 1 short description",
longDescription = "Topic 1 long description", longDescription = "Topic 1 long description",
url = "Topic 1 URL", url = "Topic 1 URL",
imageUrl = "Topic 1 image URL" imageUrl = "Topic 1 image URL",
), ),
Topic( Topic(
id = "T2", id = "T2",
@ -62,9 +61,9 @@ class UserNewsResourceTest {
shortDescription = "Topic 2 short description", shortDescription = "Topic 2 short description",
longDescription = "Topic 2 long description", longDescription = "Topic 2 long description",
url = "Topic 2 URL", url = "Topic 2 URL",
imageUrl = "Topic 2 image URL" imageUrl = "Topic 2 image URL",
), ),
) ),
) )
val userData = UserData( val userData = UserData(
@ -72,7 +71,8 @@ class UserNewsResourceTest {
followedTopics = setOf("T1"), followedTopics = setOf("T1"),
themeBrand = DEFAULT, themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM, darkThemeConfig = FOLLOW_SYSTEM,
shouldHideOnboarding = true useDynamicColor = false,
shouldHideOnboarding = true,
) )
val userNewsResource = UserNewsResource(newsResource1, userData) val userNewsResource = UserNewsResource(newsResource1, userData)
@ -88,11 +88,10 @@ class UserNewsResourceTest {
// Check that each Topic has been converted to a FollowedTopic correctly. // Check that each Topic has been converted to a FollowedTopic correctly.
assertEquals(newsResource1.topics.size, userNewsResource.followableTopics.size) assertEquals(newsResource1.topics.size, userNewsResource.followableTopics.size)
for (topic in newsResource1.topics) { for (topic in newsResource1.topics) {
// Construct the expected FollowableTopic. // Construct the expected FollowableTopic.
val followableTopic = FollowableTopic( val followableTopic = FollowableTopic(
topic = topic, topic = topic,
isFollowed = userData.followedTopics.contains(topic.id) isFollowed = userData.followedTopics.contains(topic.id),
) )
assertTrue(userNewsResource.followableTopics.contains(followableTopic)) assertTrue(userNewsResource.followableTopics.contains(followableTopic))
} }
@ -100,7 +99,7 @@ class UserNewsResourceTest {
// Check that the saved flag is set correctly. // Check that the saved flag is set correctly.
assertEquals( assertEquals(
userData.bookmarkedNewsResources.contains(newsResource1.id), userData.bookmarkedNewsResources.contains(newsResource1.id),
userNewsResource.isSaved userNewsResource.isSaved,
) )
} }
} }

@ -16,15 +16,7 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/** /**
* External data layer representation of a fully populated NiA news resource * External data layer representation of a fully populated NiA news resource
@ -37,63 +29,5 @@ data class NewsResource(
val headerImageUrl: String?, val headerImageUrl: String?,
val publishDate: Instant, val publishDate: Instant,
val type: NewsResourceType, val type: NewsResourceType,
val topics: List<Topic> val topics: List<Topic>,
)
val previewNewsResources = listOf(
NewsResource(
id = "1",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0
).toInstant(TimeZone.UTC),
type = Codelab,
topics = listOf(previewTopics[1])
),
NewsResource(
id = "2",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(previewTopics[0], previewTopics[1])
),
NewsResource(
id = "3",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(previewTopics[2])
),
NewsResource(
id = "4",
title = "New Jetpack Release",
content = "New Jetpack release includes updates to libraries such as CameraX, Benchmark, and" +
"more!",
url = "https://developer.android.com/jetpack/androidx/versions/all-channel",
headerImageUrl = "",
publishDate = Instant.parse("2022-10-01T00:00:00.000Z"),
type = Unknown,
topics = listOf(previewTopics[2])
)
) )

@ -23,53 +23,53 @@ enum class NewsResourceType(
val serializedName: String, val serializedName: String,
val displayText: String, val displayText: String,
// TODO: descriptions should probably be string resources // TODO: descriptions should probably be string resources
val description: String val description: String,
) { ) {
Video( Video(
serializedName = "Video 📺", serializedName = "Video 📺",
displayText = "Video 📺", displayText = "Video 📺",
description = "A video published on YouTube" description = "A video published on YouTube",
), ),
APIChange( APIChange(
serializedName = "API change", serializedName = "API change",
displayText = "API change", displayText = "API change",
description = "An addition, deprecation or change to the Android platform APIs." description = "An addition, deprecation or change to the Android platform APIs.",
), ),
Article( Article(
serializedName = "Article 📚", serializedName = "Article 📚",
displayText = "Article 📚", displayText = "Article 📚",
description = "An article, typically on Medium or the official Android blog" description = "An article, typically on Medium or the official Android blog",
), ),
Codelab( Codelab(
serializedName = "Codelab", serializedName = "Codelab",
displayText = "Codelab", displayText = "Codelab",
description = "A new or updated codelab" description = "A new or updated codelab",
), ),
Podcast( Podcast(
serializedName = "Podcast 🎙", serializedName = "Podcast 🎙",
displayText = "Podcast 🎙", displayText = "Podcast 🎙",
description = "A podcast" description = "A podcast",
), ),
Docs( Docs(
serializedName = "Docs 📑", serializedName = "Docs 📑",
displayText = "Docs 📑", displayText = "Docs 📑",
description = "A new or updated piece of documentation" description = "A new or updated piece of documentation",
), ),
Event( Event(
serializedName = "Event 📆", serializedName = "Event 📆",
displayText = "Event 📆", displayText = "Event 📆",
description = "Information about a developer event e.g. Android Developer Summit" description = "Information about a developer event e.g. Android Developer Summit",
), ),
DAC( DAC(
serializedName = "DAC", serializedName = "DAC",
displayText = "DAC", displayText = "DAC",
description = "Android version features - Information about features in an Android" description = "Android version features - Information about features in an Android",
), ),
Unknown( Unknown(
serializedName = "Unknown", serializedName = "Unknown",
displayText = "Unknown", displayText = "Unknown",
description = "Unknown" description = "Unknown",
) ),
} }
fun String?.asNewsResourceType() = when (this) { fun String?.asNewsResourceType() = when (this) {

@ -16,8 +16,6 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
/* ktlint-disable max-line-length */
/** /**
* External data layer representation of a NiA Topic * External data layer representation of a NiA Topic
*/ */
@ -29,30 +27,3 @@ data class Topic(
val url: String, val url: String,
val imageUrl: String, val imageUrl: String,
) )
val previewTopics = listOf(
Topic(
id = "2",
name = "Headlines",
shortDescription = "News we want everyone to see",
longDescription = "Stay up to date with the latest events and announcements from Android!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f",
url = ""
),
Topic(
id = "3",
name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = ""
),
Topic(
id = "4",
name = "Testing",
shortDescription = "CI, Espresso, TestLab, etc",
longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428",
url = ""
),
)

@ -24,5 +24,6 @@ data class UserData(
val followedTopics: Set<String>, val followedTopics: Set<String>,
val themeBrand: ThemeBrand, val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig, val darkThemeConfig: DarkThemeConfig,
val shouldHideOnboarding: Boolean val useDynamicColor: Boolean,
val shouldHideOnboarding: Boolean,
) )

@ -23,8 +23,8 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save