Migrate the settings module to multiplatform

pull/2064/head
lihenggui 2 years ago
parent 8727d7aded
commit 75f75297c1

@ -63,7 +63,7 @@ As Firebase Analytics does not yet support Kotlin Multiplatform, the implementat
| :feature:foryou | In progress | ✔️ | ✔️ | ✔️ | ❌ |
| :feature:interests | In progress | ✔️ | ✔️ | ✔️ | ❌ |
| :feature:search | In progress | ✔️ | ✔️ | ✔️ | ❌ |
| :feature:settings | Not started | ❌ | ❌ | ❌ | ❌ |
| :feature:settings | Not started | ✔️ | ✔️ | ✔️ | ❌ |
| :feature:topic | Not started | ❌ | ❌ | ❌ | ❌ |
| lint | Not started | ❌ | ❌ | ❌ | ❌ |
| :sync:sync-test | Not started | ❌ | ❌ | ❌ | ❌ |

@ -15,8 +15,7 @@
*/
plugins {
alias(libs.plugins.nowinandroid.android.feature)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.cmp.feature)
alias(libs.plugins.nowinandroid.android.library.jacoco)
}
@ -24,12 +23,32 @@ android {
namespace = "com.google.samples.apps.nowinandroid.feature.settings"
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.google.oss.licenses)
implementation(projects.core.data)
testImplementation(projects.core.testing)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(compose.material3)
implementation(compose.foundation)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
}
commonMain.dependencies {
implementation(projects.core.testing)
}
androidUnitTest.dependencies {
implementation(libs.robolectric)
implementation(libs.roborazzi)
implementation(projects.core.screenshotTesting)
}
androidInstrumentedTest.dependencies {
implementation(projects.core.testing)
implementation(libs.bundles.androidx.compose.ui.test)
}
}
}
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
compose.resources {
publicResClass = true
}

@ -25,6 +25,22 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import kotlinx.coroutines.runBlocking
import nowinandroid.feature.settings.generated.resources.Res
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_android
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_default
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_guidelines
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_dark
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_light
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_system_default
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_no
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_preference
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_yes
import nowinandroid.feature.settings.generated.resources.feature_settings_feedback
import nowinandroid.feature.settings.generated.resources.feature_settings_licenses
import nowinandroid.feature.settings.generated.resources.feature_settings_loading
import nowinandroid.feature.settings.generated.resources.feature_settings_privacy_policy
import org.jetbrains.compose.resources.StringResource
import org.junit.Rule
import org.junit.Test
@ -33,7 +49,10 @@ class SettingsDialogTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun getString(id: Int) = composeTestRule.activity.resources.getString(id)
private fun getString(id: StringResource) = runBlocking {
// TODO remove runBlocking
org.jetbrains.compose.resources.getString(id)
}
@Test
fun whenLoading_showsLoadingText() {
@ -48,7 +67,7 @@ class SettingsDialogTest {
}
composeTestRule
.onNodeWithText(getString(R.string.feature_settings_loading))
.onNodeWithText(getString(Res.string.feature_settings_loading))
.assertExists()
}
@ -71,17 +90,17 @@ class SettingsDialogTest {
}
// Check that all the possible settings are displayed.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_default)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_brand_default)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_brand_android)).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_system_default),
getString(Res.string.feature_settings_dark_mode_config_system_default),
).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_light)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dark_mode_config_light)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dark_mode_config_dark)).assertExists()
// Check that the correct settings are selected.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertIsSelected()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertIsSelected()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_brand_android)).assertIsSelected()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dark_mode_config_dark)).assertIsSelected()
}
@Test
@ -103,12 +122,12 @@ class SettingsDialogTest {
)
}
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_preference)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_yes)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_no)).assertExists()
// Check that the correct default dynamic color setting is selected.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertIsSelected()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_no)).assertIsSelected()
}
@Test
@ -129,10 +148,10 @@ class SettingsDialogTest {
)
}
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference))
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_preference))
.assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
}
@Test
@ -153,10 +172,10 @@ class SettingsDialogTest {
)
}
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference))
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_preference))
.assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
}
@Test
@ -177,9 +196,9 @@ class SettingsDialogTest {
)
}
composeTestRule.onNodeWithText(getString(R.string.feature_settings_privacy_policy)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_licenses)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_guidelines)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_feedback)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_privacy_policy)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_licenses)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_brand_guidelines)).assertExists()
composeTestRule.onNodeWithText(getString(Res.string.feature_settings_feedback)).assertExists()
}
}

@ -18,7 +18,6 @@
package com.google.samples.apps.nowinandroid.feature.settings
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -31,7 +30,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
@ -45,17 +43,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming
@ -67,14 +59,34 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.feature.settings.R.string
import com.google.samples.apps.nowinandroid.core.ui.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import nowinandroid.feature.settings.generated.resources.Res
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_android
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_default
import nowinandroid.feature.settings.generated.resources.feature_settings_brand_guidelines
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_dark
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_light
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_config_system_default
import nowinandroid.feature.settings.generated.resources.feature_settings_dark_mode_preference
import nowinandroid.feature.settings.generated.resources.feature_settings_dismiss_dialog_button_text
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_no
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_preference
import nowinandroid.feature.settings.generated.resources.feature_settings_dynamic_color_yes
import nowinandroid.feature.settings.generated.resources.feature_settings_feedback
import nowinandroid.feature.settings.generated.resources.feature_settings_licenses
import nowinandroid.feature.settings.generated.resources.feature_settings_loading
import nowinandroid.feature.settings.generated.resources.feature_settings_privacy_policy
import nowinandroid.feature.settings.generated.resources.feature_settings_theme
import nowinandroid.feature.settings.generated.resources.feature_settings_title
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun SettingsDialog(
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
viewModel: SettingsViewModel,
) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog(
@ -95,7 +107,7 @@ fun SettingsDialog(
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
val configuration = LocalConfiguration.current
val configuration = LocalViewConfiguration.current
/**
* usePlatformDefaultWidth = false is use as a temporary fix to allow
@ -106,11 +118,10 @@ fun SettingsDialog(
*/
AlertDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.widthIn(max = configuration.screenWidthDp.dp - 80.dp),
onDismissRequest = { onDismiss() },
title = {
Text(
text = stringResource(string.feature_settings_title),
text = stringResource(Res.string.feature_settings_title),
style = MaterialTheme.typography.titleLarge,
)
},
@ -120,7 +131,7 @@ fun SettingsDialog(
when (settingsUiState) {
Loading -> {
Text(
text = stringResource(string.feature_settings_loading),
text = stringResource(Res.string.feature_settings_loading),
modifier = Modifier.padding(vertical = 16.dp),
)
}
@ -142,7 +153,7 @@ fun SettingsDialog(
},
confirmButton = {
Text(
text = stringResource(string.feature_settings_dismiss_dialog_button_text),
text = stringResource(Res.string.feature_settings_dismiss_dialog_button_text),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
@ -162,50 +173,50 @@ private fun ColumnScope.SettingsPanel(
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
SettingsDialogSectionTitle(text = stringResource(string.feature_settings_theme))
SettingsDialogSectionTitle(text = stringResource(Res.string.feature_settings_theme))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_brand_default),
text = stringResource(Res.string.feature_settings_brand_default),
selected = settings.brand == DEFAULT,
onClick = { onChangeThemeBrand(DEFAULT) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_brand_android),
text = stringResource(Res.string.feature_settings_brand_android),
selected = settings.brand == ANDROID,
onClick = { onChangeThemeBrand(ANDROID) },
)
}
AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) {
Column {
SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dynamic_color_preference))
SettingsDialogSectionTitle(text = stringResource(Res.string.feature_settings_dynamic_color_preference))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dynamic_color_yes),
text = stringResource(Res.string.feature_settings_dynamic_color_yes),
selected = settings.useDynamicColor,
onClick = { onChangeDynamicColorPreference(true) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dynamic_color_no),
text = stringResource(Res.string.feature_settings_dynamic_color_no),
selected = !settings.useDynamicColor,
onClick = { onChangeDynamicColorPreference(false) },
)
}
}
}
SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dark_mode_preference))
SettingsDialogSectionTitle(text = stringResource(Res.string.feature_settings_dark_mode_preference))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_system_default),
text = stringResource(Res.string.feature_settings_dark_mode_config_system_default),
selected = settings.darkThemeConfig == FOLLOW_SYSTEM,
onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_light),
text = stringResource(Res.string.feature_settings_dark_mode_config_light),
selected = settings.darkThemeConfig == LIGHT,
onClick = { onChangeDarkThemeConfig(LIGHT) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_dark),
text = stringResource(Res.string.feature_settings_dark_mode_config_dark),
selected = settings.darkThemeConfig == DARK,
onClick = { onChangeDarkThemeConfig(DARK) },
)
@ -261,25 +272,24 @@ private fun LinksPanel() {
NiaTextButton(
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) },
) {
Text(text = stringResource(string.feature_settings_privacy_policy))
Text(text = stringResource(Res.string.feature_settings_privacy_policy))
}
val context = LocalContext.current
NiaTextButton(
onClick = {
context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
// Intentionally left blank
},
) {
Text(text = stringResource(string.feature_settings_licenses))
Text(text = stringResource(Res.string.feature_settings_licenses))
}
NiaTextButton(
onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) },
) {
Text(text = stringResource(string.feature_settings_brand_guidelines))
Text(text = stringResource(Res.string.feature_settings_brand_guidelines))
}
NiaTextButton(
onClick = { uriHandler.openUri(FEEDBACK_URL) },
) {
Text(text = stringResource(string.feature_settings_feedback))
Text(text = stringResource(Res.string.feature_settings_feedback))
}
}
}
@ -319,5 +329,6 @@ private fun PreviewSettingsDialogLoading() {
}
private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy"
private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines"
private const val BRAND_GUIDELINES_URL =
"https://developer.android.com/distribute/marketing-tools/brand-guidelines"
private const val FEEDBACK_URL = "https://goo.gle/nia-app-feedback"

@ -23,17 +23,14 @@ 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.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SettingsViewModel @Inject constructor(
class SettingsViewModel constructor(
private val userDataRepository: UserDataRepository,
) : ViewModel() {
val settingsUiState: StateFlow<SettingsUiState> =

@ -19,28 +19,24 @@ package com.google.samples.apps.nowinandroid.feature.settings
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SettingsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private lateinit var viewModel: SettingsViewModel
@Before
@BeforeTest
fun setup() {
viewModel = SettingsViewModel(userDataRepository)
}
@ -50,6 +46,7 @@ class SettingsViewModelTest {
assertEquals(Loading, viewModel.settingsUiState.value)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun stateIsSuccessAfterUserDataLoaded() = runTest {
val collectJob =
Loading…
Cancel
Save