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

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

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

@ -29,10 +29,6 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -40,6 +36,10 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
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.
@ -57,7 +57,8 @@ class NavigationTest {
* 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.
*/
@BindValue @get:Rule(order = 1)
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
@ -165,7 +166,6 @@ class NavigationTest {
@Test
fun topLevelDestinations_showTopBarWithTitle() {
composeTestRule.apply {
// Verify that the top bar contains the app name on the first screen.
onNodeWithText(appName).assertExists()
@ -207,7 +207,6 @@ class NavigationTest {
@Test
fun whenSettingsDialogDismissed_previousScreenIsDisplayed() {
composeTestRule.apply {
// Navigate to the saved screen, open the settings dialog, then close it.
onNodeWithText(saved).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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/**
* 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
* 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()
/**
@ -77,9 +78,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -90,41 +91,41 @@ class NavigationUiTest {
}
@Test
fun mediumWidth_compactHeight_showsNavigationBar() {
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationBar() {
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
@ -134,9 +135,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -153,9 +154,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -172,9 +173,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -191,9 +192,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -210,9 +211,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -229,9 +230,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
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.testing.TestNavHostController
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.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Tests [NiaAppState].
@ -70,7 +70,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
navController = navController,
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -91,7 +91,7 @@ class NiaAppStateTest {
composeTestRule.setContent {
state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
@ -108,7 +108,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -123,7 +123,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -133,13 +133,12 @@ class NiaAppStateTest {
@Test
fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent {
state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -149,13 +148,12 @@ class NiaAppStateTest {
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -163,7 +161,7 @@ class NiaAppStateTest {
networkMonitor.setConnected(false)
assertEquals(
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.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
@ -106,7 +106,8 @@ class MainActivity : ComponentActivity() {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState)
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
NiaApp(
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
* 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.model.data.UserData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it)
}.stateIn(
scope = viewModelScope,
initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000)
started = SharingStarted.WhileSubscribed(5_000),
)
}

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

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

@ -34,24 +34,24 @@ enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int,
val titleTextId: Int
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
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.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class
ExperimentalLifecycleComposeApi::class,
)
@Composable
fun NiaApp(
@ -85,7 +85,7 @@ fun NiaApp(
networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass
windowSizeClass = windowSizeClass,
),
) {
val shouldShowGradientBackground =
@ -106,15 +106,17 @@ fun NiaApp(
// If user is not connected to the internet show a snack bar to inform them.
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite
)
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}
if (appState.shouldShowSettingsDialog) {
SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) }
onDismiss = { appState.setShowSettingsDialog(false) },
)
}
@ -132,10 +134,10 @@ fun NiaApp(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar")
modifier = Modifier.testTag("NiaBottomBar"),
)
}
}
},
) { padding ->
Row(
Modifier
@ -144,9 +146,9 @@ fun NiaApp(
.consumedWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
WindowInsetsSides.Horizontal,
),
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
@ -155,7 +157,7 @@ fun NiaApp(
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding()
.safeDrawingPadding(),
)
}
@ -167,18 +169,18 @@ fun NiaApp(
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description
id = settingsR.string.top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
containerColor = Color.Transparent,
),
onActionClick = { appState.setShowSettingsDialog(true) }
onActionClick = { appState.setShowSettingsDialog(true) },
)
}
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick
onBackClick = appState::onBackClick,
)
}
@ -212,15 +214,15 @@ private fun NiaNavRail(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
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>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
NiaNavigationBar(
modifier = modifier
modifier = modifier,
) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
@ -250,16 +252,16 @@ private fun NiaBottomBar(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
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
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
@ -56,7 +55,7 @@ fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController()
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
return remember(navController, coroutineScope, windowSizeClass, networkMonitor) {
@ -87,8 +86,7 @@ class NiaAppState(
private set
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
@ -98,7 +96,7 @@ class NiaAppState(
.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
initialValue = false,
)
/**

@ -28,7 +28,7 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
*/
fun untilHasChildren(
childCount: Int = 1,
op: HasChildrenOp = AT_LEAST
op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean {
@ -44,5 +44,5 @@ fun untilHasChildren(
enum class HasChildrenOp {
AT_LEAST,
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.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
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.forYouSelectTopics
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 org.junit.Rule
import org.junit.Test
@ -46,24 +47,16 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectTopics(true)
forYouScrollFeedDownUp()
// Navigate to saved screen
device.findObject(By.text("Saved")).click()
device.waitForIdle()
goToBookmarksScreen()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
goToInterestsScreen()
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.test.uiautomator.By
import androidx.test.uiautomator.Until
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() {
val feedList = device.findObject(By.res("bookmarks:feed"))
device.flingElementDownUp(feedList)

@ -23,12 +23,65 @@ import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if authors are loaded
device.wait(Until.gone(By.res("forYou:loadingWheel")), 5_000)
// Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet
// So we'll wait here for authors to be sure
val obj = device.findObject(By.res("forYou:authors"))
obj.wait(untilHasChildren(), 30_000)
// So we'll wait here for topics to be sure
val obj = device.findObject(By.res("forYou:topicSelection"))
// 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() {

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

@ -21,16 +21,23 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
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() {
val topicsList = device.findObject(By.res("interests:topics"))
device.flingElementDownUp(topicsList)
}
fun MacrobenchmarkScope.interestsScrollPeopleDownUp() {
val peopleList = device.findObject(By.res("interests:people"))
device.flingElementDownUp(peopleList)
}
fun MacrobenchmarkScope.interestsWaitForTopics() {
device.wait(Until.hasObject(By.text("Accessibility")), 30_000)
}

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

@ -66,7 +66,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
@Test
fun startupBaselineProfileDisabled() = startup(
CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1)
CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1),
)
@Test
@ -83,7 +83,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
startupMode = startupMode,
setupBlock = {
pressHome()
}
},
) {
startActivityAndWait()
// 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)
enum class NiaDispatchers {
IO
IO,
}

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

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

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

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.data
import android.util.Log
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlin.coroutines.cancellation.CancellationException
/**
* 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(
"suspendRunCatching",
"Failed to evaluate a suspendRunCatchingBlock. Returning failure Result",
exception
exception,
)
Result.failure(exception)
}
@ -116,10 +116,10 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(
combine(flow, flow2, flow3, ::Triple),
combine(flow4, flow5, flow6, ::Triple)
combine(flow4, flow5, flow6, ::Triple),
) { t1, t2 ->
transform(
t1.first,
@ -127,6 +127,6 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
t1.third,
t2.first,
t2.second,
t2.third
t2.third,
)
}

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

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

@ -25,5 +25,5 @@ fun NetworkTopic.asEntity() = TopicEntity(
shortDescription = shortDescription,
longDescription = longDescription,
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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Disk storage backed implementation of the [NewsRepository].
@ -49,9 +49,9 @@ class OfflineFirstNewsRepository @Inject constructor(
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources(
filterTopicIds: Set<String>
filterTopicIds: Set<String>,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterTopicIds = filterTopicIds
filterTopicIds = filterTopicIds,
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
@ -74,18 +74,18 @@ class OfflineFirstNewsRepository @Inject constructor(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
.map(NetworkNewsResource::asEntity),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Disk storage backed implementation of the [TopicsRepository].
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds)
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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource
private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository {
override val userData: Flow<UserData> =
@ -45,6 +45,9 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) =
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) =
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}

@ -53,6 +53,11 @@ interface UserDataRepository {
*/
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.
*/

@ -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.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
@ -48,7 +48,7 @@ class FakeNewsRepository @Inject constructor(
emit(
datasource.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
.map(NewsResourceEntity::asExternalModel),
)
}.flowOn(ioDispatcher)
@ -61,7 +61,7 @@ class FakeNewsRepository @Inject constructor(
.getNewsResources()
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
.map(NewsResourceEntity::asExternalModel),
)
}.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.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = flow {
emit(
@ -49,9 +49,9 @@ class FakeTopicsRepository @Inject constructor(
shortDescription = it.shortDescription,
longDescription = it.longDescription,
url = it.url,
imageUrl = it.imageUrl
imageUrl = it.imageUrl,
)
}
},
)
}.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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Fake implementation of the [UserDataRepository] that returns hardcoded user data.
@ -55,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
}
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}

@ -26,14 +26,14 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
@ -54,7 +54,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
networkCapabilities: NetworkCapabilities,
) {
channel.trySend(connectivityManager.isCurrentlyConnected())
}
@ -64,7 +64,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback
callback,
)
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.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
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.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstNewsRepositoryTest {
@ -65,8 +65,8 @@ class OfflineFirstNewsRepositoryTest {
network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer(
NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
tmpFolder.testUserPreferencesDataStore(),
),
)
subject = OfflineFirstNewsRepository(
@ -84,7 +84,7 @@ class OfflineFirstNewsRepositoryTest {
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources()
.first()
.first(),
)
}
@ -100,7 +100,7 @@ class OfflineFirstNewsRepositoryTest {
subject.getNewsResources(
filterTopicIds = filteredInterestsIds,
)
.first()
.first(),
)
assertEquals(
@ -108,7 +108,7 @@ class OfflineFirstNewsRepositoryTest {
subject.getNewsResources(
filterTopicIds = nonPresentInterestsIds,
)
.first()
.first(),
)
}
@ -127,13 +127,13 @@ class OfflineFirstNewsRepositoryTest {
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id)
newsResourcesFromDb.map(NewsResource::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion
synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@ -155,7 +155,7 @@ class OfflineFirstNewsRepositoryTest {
network.editCollection(
collectionType = CollectionType.NewsResources,
id = it,
isDelete = true
isDelete = true,
)
}
@ -168,13 +168,13 @@ class OfflineFirstNewsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems,
newsResourcesFromDb.map(NewsResource::id)
newsResourcesFromDb.map(NewsResource::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion
synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@ -190,7 +190,7 @@ class OfflineFirstNewsRepositoryTest {
val changeList = network.changeListsAfter(
CollectionType.NewsResources,
version = 7
version = 7,
)
val changeListIds = changeList
.map(NetworkChangeList::id)
@ -207,13 +207,13 @@ class OfflineFirstNewsRepositoryTest {
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id)
newsResourcesFromDb.map(NewsResource::id),
)
// After sync version should be updated
assertEquals(
changeList.last().changeListVersion,
synchronizer.getChangeListVersions().newsResourceVersion
synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@ -228,7 +228,7 @@ class OfflineFirstNewsRepositoryTest {
.flatten()
.distinctBy(TopicEntity::id),
topicDao.getTopicEntities()
.first()
.first(),
)
}
@ -242,7 +242,7 @@ class OfflineFirstNewsRepositoryTest {
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest {
@ -56,13 +56,13 @@ class OfflineFirstTopicsRepositoryTest {
topicDao = TestTopicDao()
network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
tmpFolder.testUserPreferencesDataStore(),
)
synchronizer = TestSynchronizer(niaPreferences)
subject = OfflineFirstTopicsRepository(
topicDao = topicDao,
network = network
network = network,
)
}
@ -74,7 +74,7 @@ class OfflineFirstTopicsRepositoryTest {
.first()
.map(TopicEntity::asExternalModel),
subject.getTopics()
.first()
.first(),
)
}
@ -91,13 +91,13 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id)
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion
synchronizer.getChangeListVersions().topicVersion,
)
}
@ -121,13 +121,13 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id)
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion
synchronizer.getChangeListVersions().topicVersion,
)
}
@ -149,7 +149,7 @@ class OfflineFirstTopicsRepositoryTest {
network.editCollection(
collectionType = CollectionType.Topics,
id = it,
isDelete = true
isDelete = true,
)
}
@ -162,13 +162,13 @@ class OfflineFirstTopicsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
networkTopics.map(Topic::id) - deletedItems,
dbTopics.map(Topic::id)
dbTopics.map(Topic::id),
)
// After sync version should be updated
assertEquals(
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.ThemeBrand
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.map
import kotlinx.coroutines.test.runTest
@ -31,6 +28,9 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest {
private lateinit var subject: OfflineFirstUserDataRepository
@ -43,11 +43,11 @@ class OfflineFirstUserDataRepositoryTest {
@Before
fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
tmpFolder.testUserPreferencesDataStore(),
)
subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource
niaPreferencesDataSource = niaPreferencesDataSource,
)
}
@ -60,9 +60,10 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false
useDynamicColor = false,
shouldHideOnboarding = false,
),
subject.userData.first()
subject.userData.first(),
)
}
@ -75,7 +76,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
@ -84,7 +85,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
assertEquals(
@ -93,7 +94,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
}
@ -106,7 +107,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("1", "2"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
assertEquals(
@ -115,7 +116,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
}
@ -128,7 +129,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0"),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
@ -137,7 +138,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
assertEquals(
@ -146,7 +147,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
}
@ -159,14 +160,34 @@ class OfflineFirstUserDataRepositoryTest {
ThemeBrand.ANDROID,
subject.userData
.map { it.themeBrand }
.first()
.first(),
)
assertEquals(
ThemeBrand.ANDROID,
niaPreferencesDataSource
.userData
.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,
subject.userData
.map { it.darkThemeConfig }
.first()
.first(),
)
assertEquals(
DarkThemeConfig.DARK,
niaPreferencesDataSource
.userData
.map { it.darkThemeConfig }
.first()
.first(),
)
}

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

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

@ -27,7 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType {
Topics,
NewsResources
NewsResources,
}
/**
@ -37,7 +37,7 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true }
Json { ignoreUnknownKeys = true },
)
private val allTopics = runBlocking { source.getTopics() }
@ -54,13 +54,13 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds(
ids = ids,
idGetter = NetworkTopic::id
idGetter = NetworkTopic::id,
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds(
ids = ids,
idGetter = NetworkNewsResource::id
idGetter = NetworkNewsResource::id,
)
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(
ids: List<String>?,
idGetter: (T) -> String
idGetter: (T) -> String,
) = when (ids) {
null -> this
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
*/
private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String
idGetter: (T) -> String,
) = mapIndexed { index, item ->
NetworkChangeList(
id = idGetter(item),

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

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

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
@ -31,7 +34,8 @@ android {
arg("room.schemaLocation", "$projectDir/schemas")
}
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.database"
@ -39,7 +43,7 @@ android {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<com.android.build.api.dsl.ManagedVirtualDevice>("pixel4api30").apply {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 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.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest {
@ -43,7 +43,7 @@ class NewsResourceDaoTest {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context,
NiaDatabase::class.java
NiaDatabase::class.java,
).build()
newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao()
@ -70,7 +70,7 @@ class NewsResourceDaoTest {
),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities
newsResourceEntities,
)
val savedNewsResourceEntities = newsResourceDao.getNewsResources()
@ -80,7 +80,7 @@ class NewsResourceDaoTest {
listOf(3L, 2L, 1L, 0L),
savedNewsResourceEntities.map {
it.asExternalModel().publishDate.toEpochMilliseconds()
}
},
)
}
@ -89,11 +89,11 @@ class NewsResourceDaoTest {
val topicEntities = listOf(
testTopicEntity(
id = "1",
name = "1"
name = "1",
),
testTopicEntity(
id = "2",
name = "2"
name = "2",
),
)
val newsResourceEntities = listOf(
@ -117,18 +117,18 @@ class NewsResourceDaoTest {
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef(
newsResourceId = index.toString(),
topicId = topicEntity.id
topicId = topicEntity.id,
)
}
topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities
topicEntities = topicEntities,
)
newsResourceDao.upsertNewsResources(
newsResourceEntities
newsResourceEntities,
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossRefEntities
newsResourceTopicCrossRefEntities,
)
val filteredNewsResources = newsResourceDao.getNewsResources(
@ -139,7 +139,7 @@ class NewsResourceDaoTest {
assertEquals(
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 }
newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id)
toDelete.map(NewsResourceEntity::id),
)
assertEquals(
@ -177,26 +177,26 @@ class NewsResourceDaoTest {
.toSet(),
newsResourceDao.getNewsResources().first()
.map { it.entity.id }
.toSet()
.toSet(),
)
}
}
private fun testTopicEntity(
id: String = "0",
name: String
name: String,
) = TopicEntity(
id = id,
name = name,
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
imageUrl = "",
)
private fun testNewsResource(
id: String = "0",
millisSinceEpoch: Long = 0
millisSinceEpoch: Long = 0,
) = NewsResourceEntity(
id = id,
title = "",

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

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

@ -46,7 +46,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
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,
)

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

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

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

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

@ -34,9 +34,9 @@ data class PopulatedNewsResource(
value = NewsResourceTopicCrossRef::class,
parentColumn = "news_resource_id",
entityColumn = "topic_id",
)
),
)
val topics: List<TopicEntity>
val topics: List<TopicEntity>,
)
fun PopulatedNewsResource.asExternalModel() = NewsResource(
@ -47,5 +47,5 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
headerImageUrl = entity.headerImageUrl,
publishDate = entity.publishDate,
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.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
import org.junit.rules.TemporaryFolder
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataStoreModule::class]
replaces = [DataStoreModule::class],
)
object TestDataStoreModule {
@ -39,13 +39,13 @@ object TestDataStoreModule {
@Singleton
fun providesUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder
tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer)
}
fun TemporaryFolder.testUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer()
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create(
serializer = userPreferencesSerializer,
) {

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

@ -30,21 +30,21 @@ object ListToMapMigration : DataMigration<UserPreferences> {
// Migrate topic id lists
followedTopicIds.clear()
followedTopicIds.putAll(
currentData.deprecatedFollowedTopicIdsList.associateWith { true }
currentData.deprecatedFollowedTopicIdsList.associateWith { true },
)
deprecatedFollowedTopicIds.clear()
// Migrate author ids
followedAuthorIds.clear()
followedAuthorIds.putAll(
currentData.deprecatedFollowedAuthorIdsList.associateWith { true }
currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
)
deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks
bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.putAll(
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true }
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
)
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.ThemeBrand
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.map
import java.io.IOException
import javax.inject.Inject
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>
private val userPreferences: DataStore<UserPreferences>,
) {
val userData = userPreferences.data
.map {
UserData(
@ -39,20 +38,23 @@ class NiaPreferencesDataSource @Inject constructor(
null,
ThemeBrandProto.THEME_BRAND_UNSPECIFIED,
ThemeBrandProto.UNRECOGNIZED,
ThemeBrandProto.THEME_BRAND_DEFAULT -> ThemeBrand.DEFAULT
ThemeBrandProto.THEME_BRAND_DEFAULT,
-> ThemeBrand.DEFAULT
ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID
},
darkThemeConfig = when (it.darkThemeConfig) {
null,
DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,
DarkThemeConfigProto.UNRECOGNIZED,
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM ->
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM,
->
DarkThemeConfig.FOLLOW_SYSTEM
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT
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) {
userPreferences.updateData {
it.copy {
@ -145,8 +155,8 @@ class NiaPreferencesDataSource @Inject constructor(
val updatedChangeListVersions = update(
ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion
)
newsResourceVersion = currentPreferences.newsResourceChangeListVersion,
),
)
currentPreferences.copy {
@ -169,7 +179,6 @@ class NiaPreferencesDataSource @Inject constructor(
}
private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() {
if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
shouldHideOnboarding = false
}

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

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

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

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

@ -17,14 +17,14 @@
package com.google.samples.apps.nowinandroid.core.datastore
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.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest {
private lateinit var subject: NiaPreferencesDataSource
@ -35,7 +35,7 @@ class NiaPreferencesDataSourceTest {
@Before
fun setup() {
subject = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
tmpFolder.testUserPreferencesDataStore(),
)
}
@ -52,7 +52,6 @@ class NiaPreferencesDataSourceTest {
@Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single topic.
subject.toggleFollowedTopicId("1", true)
subject.setShouldHideOnboarding(true)
@ -66,7 +65,6 @@ class NiaPreferencesDataSourceTest {
@Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several topics.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true)
@ -77,4 +75,15 @@ class NiaPreferencesDataSourceTest {
// Then: onboarding should be shown again
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
import androidx.datastore.core.CorruptionException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserPreferencesSerializerTest {
private val userPreferencesSerializer = UserPreferencesSerializer()
@ -32,7 +32,7 @@ class UserPreferencesSerializerTest {
userPreferences {
// Default value
},
userPreferencesSerializer.defaultValue
userPreferencesSerializer.defaultValue,
)
}
@ -53,7 +53,7 @@ class UserPreferencesSerializerTest {
assertEquals(
expectedUserPreferences,
actualUserPreferences
actualUserPreferences,
)
}

@ -31,6 +31,7 @@ android {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt.compose)
api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended)
@ -41,4 +42,4 @@ dependencies {
api(libs.androidx.compose.runtime)
lintPublish(project(":lint"))
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.LocalBackgroundTheme
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 kotlin.test.assertEquals
import com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
/**
* Tests [NiaTheme] using different combinations of the theme mode parameters:
@ -62,7 +64,7 @@ class ThemeTest {
NiaTheme(
darkTheme = false,
disableDynamicTheming = true,
androidTheme = false
androidTheme = false,
) {
val colorScheme = LightDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -70,6 +72,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -80,7 +84,7 @@ class ThemeTest {
NiaTheme(
darkTheme = true,
disableDynamicTheming = true,
androidTheme = false
androidTheme = false,
) {
val colorScheme = DarkDefaultColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -88,6 +92,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -97,7 +103,8 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
androidTheme = false
disableDynamicTheming = false,
androidTheme = false,
) {
val colorScheme = dynamicLightColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -105,6 +112,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintThemeWithFallback(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -114,7 +123,8 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
androidTheme = false
disableDynamicTheming = false,
androidTheme = false,
) {
val colorScheme = dynamicDarkColorSchemeWithFallback()
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -122,6 +132,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintThemeWithFallback(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -132,7 +144,7 @@ class ThemeTest {
NiaTheme(
darkTheme = false,
disableDynamicTheming = true,
androidTheme = true
androidTheme = true,
) {
val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -140,6 +152,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -150,7 +164,7 @@ class ThemeTest {
NiaTheme(
darkTheme = true,
disableDynamicTheming = true,
androidTheme = true
androidTheme = true,
) {
val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -158,6 +172,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -167,7 +183,8 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
androidTheme = true
disableDynamicTheming = false,
androidTheme = true,
) {
val colorScheme = LightAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -175,6 +192,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -184,7 +203,8 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
androidTheme = true
disableDynamicTheming = false,
androidTheme = true,
) {
val colorScheme = DarkAndroidColorScheme
assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme)
@ -192,6 +212,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -222,7 +244,7 @@ class ThemeTest {
return GradientColors(
top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer,
container = colorScheme.surface
container = colorScheme.surface,
)
}
@ -237,16 +259,28 @@ class ThemeTest {
private fun defaultBackgroundTheme(colorScheme: ColorScheme): BackgroundTheme {
return BackgroundTheme(
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.
*/
private fun assertColorSchemesEqual(
expectedColorScheme: ColorScheme,
actualColorScheme: ColorScheme
actualColorScheme: ColorScheme,
) {
assertEquals(expectedColorScheme.primary, actualColorScheme.primary)
assertEquals(expectedColorScheme.onPrimary, actualColorScheme.onPrimary)
@ -257,7 +291,7 @@ class ThemeTest {
assertEquals(expectedColorScheme.secondaryContainer, actualColorScheme.secondaryContainer)
assertEquals(
expectedColorScheme.onSecondaryContainer,
actualColorScheme.onSecondaryContainer
actualColorScheme.onSecondaryContainer,
)
assertEquals(expectedColorScheme.tertiary, actualColorScheme.tertiary)
assertEquals(expectedColorScheme.onTertiary, actualColorScheme.onTertiary)

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

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

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

@ -61,7 +61,7 @@ fun <T> NiaDropdownMenuButton(
text: @Composable () -> Unit,
itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
@ -69,7 +69,7 @@ fun <T> NiaDropdownMenuButton(
onClick = { expanded = true },
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground
contentColor = MaterialTheme.colorScheme.onBackground,
),
border = BorderStroke(
width = NiaDropdownMenuDefaults.DropdownMenuButtonBorderWidth,
@ -77,11 +77,11 @@ fun <T> NiaDropdownMenuButton(
MaterialTheme.colorScheme.outline
} else {
MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaDropdownMenuDefaults.DisabledDropdownMenuButtonBorderAlpha
alpha = NiaDropdownMenuDefaults.DisabledDropdownMenuButtonBorderAlpha,
)
}
},
),
contentPadding = NiaDropdownMenuDefaults.DropdownMenuButtonContentPadding
contentPadding = NiaDropdownMenuDefaults.DropdownMenuButtonContentPadding,
) {
NiaDropdownMenuButtonContent(
text = text,
@ -92,9 +92,9 @@ fun <T> NiaDropdownMenuButton(
} else {
NiaIcons.ArrowDropDown
},
contentDescription = null
contentDescription = null,
)
}
},
)
}
NiaDropdownMenu(
@ -105,7 +105,7 @@ fun <T> NiaDropdownMenuButton(
dismissOnItemClick = dismissOnItemClick,
itemText = itemText,
itemLeadingIcon = itemLeadingIcon,
itemTrailingIcon = itemTrailingIcon
itemTrailingIcon = itemTrailingIcon,
)
}
}
@ -129,8 +129,8 @@ private fun NiaDropdownMenuButtonContent(
ButtonDefaults.IconSpacing
} else {
0.dp
}
)
},
),
) {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
text()
@ -166,11 +166,11 @@ fun <T> NiaDropdownMenu(
dismissOnItemClick: Boolean = true,
itemText: @Composable (item: T) -> Unit,
itemLeadingIcon: @Composable ((item: T) -> Unit)? = null,
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null
itemTrailingIcon: @Composable ((item: T) -> Unit)? = null,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest
onDismissRequest = onDismissRequest,
) {
items.forEach { item ->
DropdownMenuItem(
@ -188,7 +188,7 @@ fun <T> NiaDropdownMenu(
{ itemTrailingIcon(item) }
} else {
null
}
},
)
}
}
@ -201,9 +201,11 @@ object NiaDropdownMenuDefaults {
// TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default
const val DisabledDropdownMenuButtonBorderAlpha = 0.12f
// TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults
val DropdownMenuButtonBorderWidth = 1.dp
// TODO: File bug
// Various default button padding values aren't exposed via ButtonDefaults
val DropdownMenuButtonContentPadding =
@ -211,6 +213,6 @@ object NiaDropdownMenuDefaults {
start = 24.dp,
top = 8.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,
enabled: Boolean = true,
icon: @Composable () -> Unit,
checkedIcon: @Composable () -> Unit = icon
checkedIcon: @Composable () -> Unit = icon,
) {
// TODO: File bug
// Can't use regular IconToggleButton as it doesn't include a shape (appears square)
@ -57,12 +57,12 @@ fun NiaIconToggleButton(
checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha,
)
} else {
Color.Transparent
}
)
},
),
) {
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.graphicsLayer
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@ -52,7 +53,7 @@ import kotlinx.coroutines.launch
@Composable
fun NiaLoadingWheel(
contentDesc: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val infiniteTransition = rememberInfiniteTransition()
@ -67,8 +68,8 @@ fun NiaLoadingWheel(
animationSpec = tween(
durationMillis = 100,
easing = FastOutSlowInEasing,
delayMillis = 40 * index
)
delayMillis = 40 * index,
),
)
}
}
@ -79,8 +80,8 @@ fun NiaLoadingWheel(
initialValue = 0F,
targetValue = 360F,
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
@ -97,8 +98,8 @@ fun NiaLoadingWheel(
baseLineColor at ROTATION_TIME / NUM_OF_LINES with LinearEasing
},
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)
.graphicsLayer { rotationZ = rotationAnim }
.semantics { contentDescription = contentDesc }
.testTag("loadingWheel"),
) {
repeat(NUM_OF_LINES) { index ->
rotate(degrees = index * 30f) {
@ -119,7 +121,7 @@ fun NiaLoadingWheel(
strokeWidth = 4F,
cap = StrokeCap.Round,
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
fun NiaOverlayLoadingWheel(
contentDesc: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Surface(
shape = RoundedCornerShape(60.dp),

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

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

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

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

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

@ -27,7 +27,7 @@ import androidx.compose.ui.unit.Dp
@Immutable
data class BackgroundTheme(
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(
val top: 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,
inverseSurface = DarkPurpleGray20,
inverseOnSurface = DarkPurpleGray95,
outline = PurpleGray50
outline = PurpleGray50,
)
/**
@ -93,7 +93,7 @@ val DarkDefaultColorScheme = darkColorScheme(
onSurfaceVariant = PurpleGray80,
inverseSurface = DarkPurpleGray90,
inverseOnSurface = DarkPurpleGray10,
outline = PurpleGray60
outline = PurpleGray60,
)
/**
@ -125,7 +125,7 @@ val LightAndroidColorScheme = lightColorScheme(
onSurfaceVariant = GreenGray30,
inverseSurface = DarkGreenGray20,
inverseOnSurface = DarkGreenGray95,
outline = GreenGray50
outline = GreenGray50,
)
/**
@ -157,7 +157,7 @@ val DarkAndroidColorScheme = darkColorScheme(
onSurfaceVariant = GreenGray80,
inverseSurface = DarkGreenGray90,
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 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.
* @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is
* supported. This parameter has no effect if [androidTheme] is `true`.
*/
@Composable
internal fun NiaTheme(
fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false,
disableDynamicTheming: Boolean,
content: @Composable () -> Unit
disableDynamicTheming: Boolean = true,
content: @Composable () -> Unit,
) {
// Color scheme
val colorScheme = when {
@ -223,6 +203,7 @@ internal fun NiaTheme(
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
}
// Gradient colors
@ -230,7 +211,7 @@ internal fun NiaTheme(
val defaultGradientColors = GradientColors(
top = colorScheme.inverseOnSurface,
bottom = colorScheme.primaryContainer,
container = colorScheme.surface
container = colorScheme.surface,
)
val gradientColors = when {
androidTheme -> if (darkTheme) DarkAndroidGradientColors else LightAndroidGradientColors
@ -240,24 +221,30 @@ internal fun NiaTheme(
// Background theme
val defaultBackgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp
tonalElevation = 2.dp,
)
val backgroundTheme = when {
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme
}
val tintTheme = when {
androidTheme -> TintTheme()
!disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary)
else -> TintTheme()
}
// Composition locals
CompositionLocalProvider(
LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme
LocalBackgroundTheme provides backgroundTheme,
LocalTintTheme provides tintTheme,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = NiaTypography,
content = content
content = content,
)
}
}
@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,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
displaySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp
letterSpacing = 0.1.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
letterSpacing = 0.4.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 10.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.NONE
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/**
* A use case which obtains a list of topics with their followed state.
*/
class GetFollowableTopicsUseCase @Inject constructor(
private val topicsRepository: TopicsRepository,
private val userDataRepository: UserDataRepository
private val userDataRepository: UserDataRepository,
) {
/**
* 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>> {
return combine(
userDataRepository.userData,
topicsRepository.getTopics()
topicsRepository.getTopics(),
) { userData, topics ->
val followedTopics = topics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics
isFollowed = topic.id in userData.followedTopics,
)
}
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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import javax.inject.Inject
/**
* 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(
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.
@ -42,7 +42,7 @@ class GetUserNewsResourcesUseCase @Inject constructor(
* this is empty the list of news resources will not be filtered.
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet()
filterTopicIds: Set<String> = emptySet(),
): Flow<List<UserNewsResource>> =
if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources()
@ -52,7 +52,7 @@ class GetUserNewsResourcesUseCase @Inject constructor(
}
private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>
userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() }
.combine(userDataStream) { newsResources, userData ->

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

@ -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.NewsResourceType
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/**
* A [NewsResource] with additional user information such as whether the user is following the
@ -42,7 +34,7 @@ data class UserNewsResource internal constructor(
val publishDate: Instant,
val type: NewsResourceType,
val followableTopics: List<FollowableTopic>,
val isSaved: Boolean
val isSaved: Boolean,
) {
constructor(newsResource: NewsResource, userData: UserData) : this(
id = newsResource.id,
@ -55,75 +47,13 @@ data class UserNewsResource internal constructor(
followableTopics = newsResource.topics.map { topic ->
FollowableTopic(
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> {
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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
class GetFollowableTopicsUseCaseTest {
@ -38,12 +38,11 @@ class GetFollowableTopicsUseCaseTest {
val useCase = GetFollowableTopicsUseCase(
topicsRepository,
userDataRepository
userDataRepository,
)
@Test
fun whenNoParams_followableTopicsAreReturnedWithNoSorting() = runTest {
// Obtain a stream of followable topics.
val followableTopics = useCase()
@ -58,16 +57,15 @@ class GetFollowableTopicsUseCaseTest {
FollowableTopic(testTopics[1], false),
FollowableTopic(testTopics[2], true),
),
followableTopics.first()
followableTopics.first(),
)
}
@Test
fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest {
// Obtain a stream of followable topics, sorted by name.
val followableTopics = useCase(
sortBy = NAME
sortBy = NAME,
)
// Send some test topics and their followed state.
@ -81,7 +79,7 @@ class GetFollowableTopicsUseCaseTest {
.sortedBy { it.name }
.map {
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.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
class GetUserNewsResourcesUseCaseTest {
@ -43,7 +43,6 @@ class GetUserNewsResourcesUseCaseTest {
@Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream.
val userNewsResources = useCase()
@ -53,7 +52,7 @@ class GetUserNewsResourcesUseCaseTest {
// Construct the test user data with bookmarks and followed topics.
val userData = emptyUserData.copy(
bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
followedTopics = setOf(sampleTopic1.id)
followedTopics = setOf(sampleTopic1.id),
)
userDataRepository.setUserData(userData)
@ -61,13 +60,12 @@ class GetUserNewsResourcesUseCaseTest {
// Check that the correct news resources are returned with their bookmarked state.
assertEquals(
sampleNewsResources.mapToUserNewsResources(userData),
userNewsResources.first()
userNewsResources.first(),
)
}
@Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
@ -80,7 +78,7 @@ class GetUserNewsResourcesUseCaseTest {
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(emptyUserData),
userNewsResources.first()
userNewsResources.first(),
)
}
}
@ -115,7 +113,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic1)
topics = listOf(sampleTopic1),
),
NewsResource(
id = "2",
@ -127,7 +125,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic1, sampleTopic2)
topics = listOf(sampleTopic1, sampleTopic2),
),
NewsResource(
id = "3",
@ -137,6 +135,6 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic2)
topics = listOf(sampleTopic2),
),
)

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

@ -16,15 +16,7 @@
package com.google.samples.apps.nowinandroid.core.model.data
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/**
* External data layer representation of a fully populated NiA news resource
@ -37,63 +29,5 @@ data class NewsResource(
val headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
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])
)
val topics: List<Topic>,
)

@ -23,53 +23,53 @@ enum class NewsResourceType(
val serializedName: String,
val displayText: String,
// TODO: descriptions should probably be string resources
val description: String
val description: String,
) {
Video(
serializedName = "Video 📺",
displayText = "Video 📺",
description = "A video published on YouTube"
description = "A video published on YouTube",
),
APIChange(
serializedName = "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(
serializedName = "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(
serializedName = "Codelab",
displayText = "Codelab",
description = "A new or updated codelab"
description = "A new or updated codelab",
),
Podcast(
serializedName = "Podcast 🎙",
displayText = "Podcast 🎙",
description = "A podcast"
description = "A podcast",
),
Docs(
serializedName = "Docs 📑",
displayText = "Docs 📑",
description = "A new or updated piece of documentation"
description = "A new or updated piece of documentation",
),
Event(
serializedName = "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(
serializedName = "DAC",
displayText = "DAC",
description = "Android version features - Information about features in an Android"
description = "Android version features - Information about features in an Android",
),
Unknown(
serializedName = "Unknown",
displayText = "Unknown",
description = "Unknown"
)
description = "Unknown",
),
}
fun String?.asNewsResourceType() = when (this) {

@ -16,8 +16,6 @@
package com.google.samples.apps.nowinandroid.core.model.data
/* ktlint-disable max-line-length */
/**
* External data layer representation of a NiA Topic
*/
@ -29,30 +27,3 @@ data class Topic(
val url: 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 themeBrand: ThemeBrand,
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.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)

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

Loading…
Cancel
Save