Merge pull request #542 from android/as/dynamic-color-option

Add dynamic color option in settings for API >=32
pull/543/head
Angie Sasmita 2 years ago committed by GitHub
commit cfbf782613
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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.

@ -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.
*/

@ -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)
}

@ -60,6 +60,7 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = false
),
subject.userData.first()
@ -170,6 +171,26 @@ class OfflineFirstUserDataRepositoryTest {
)
}
@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()
)
}
@Test
fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() =
runTest {

@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.map
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>
) {
val userData = userPreferences.data
.map {
UserData(
@ -52,6 +51,7 @@ class NiaPreferencesDataSource @Inject constructor(
DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
},
useDynamicColor = it.useDynamicColor,
shouldHideOnboarding = it.shouldHideOnboarding
)
}
@ -98,6 +98,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 {

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

@ -77,4 +77,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)
}
}

@ -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,7 +38,9 @@ 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 com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme
import kotlin.test.assertEquals
import org.junit.Rule
import org.junit.Test
@ -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)
}
}
}
@ -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,6 +103,7 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
disableDynamicTheming = false,
androidTheme = false
) {
val colorScheme = dynamicLightColorSchemeWithFallback()
@ -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,6 +123,7 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
disableDynamicTheming = false,
androidTheme = false
) {
val colorScheme = dynamicDarkColorSchemeWithFallback()
@ -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)
}
}
}
@ -140,6 +152,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -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,6 +183,7 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = false,
disableDynamicTheming = false,
androidTheme = true
) {
val colorScheme = LightAndroidColorScheme
@ -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,6 +203,7 @@ class ThemeTest {
composeTestRule.setContent {
NiaTheme(
darkTheme = true,
disableDynamicTheming = false,
androidTheme = true
) {
val colorScheme = DarkAndroidColorScheme
@ -192,6 +212,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
}
}
}
@ -241,6 +263,18 @@ class ThemeTest {
)
}
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.
*/

@ -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 = {})
}
}

@ -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
)
}

@ -185,35 +185,15 @@ 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,
disableDynamicTheming: Boolean = true,
content: @Composable () -> Unit
) {
// Color scheme
@ -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
@ -246,10 +227,16 @@ internal fun NiaTheme(
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,
@ -260,4 +247,4 @@ internal fun NiaTheme(
}
@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() }

@ -72,6 +72,7 @@ class UserNewsResourceTest {
followedTopics = setOf("T1"),
themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = true
)

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

@ -30,6 +30,7 @@ val emptyUserData = UserData(
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = false
)
@ -77,6 +78,12 @@ class TestUserDataRepository : UserDataRepository {
}
}
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(useDynamicColor = useDynamicColor))
}
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding))

@ -40,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -51,6 +52,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.previewUserNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -142,9 +144,11 @@ private fun EmptyState(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val iconTint = LocalTintTheme.current.iconTint
Image(
modifier = Modifier.fillMaxWidth(),
painter = painterResource(id = R.drawable.img_empty_bookmarks),
colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null,
contentDescription = null
)

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 The Android Open Source Project
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.
@ -15,17 +15,43 @@
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="192dp"
android:height="192dp"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:fillColor="#8B418F"
android:pathData="M16,1C15.448,1 15,1.448 15,2C15,2.552 15.448,3 16,3V1ZM58,52C58,52.552 58.448,53 59,53C59.552,53 60,52.552 60,52H58ZM16,3H55V1H16V3ZM58,6V52H60V6H58ZM55,3C56.657,3 58,4.343 58,6H60C60,3.239 57.761,1 55,1V3Z" />
<path
android:fillColor="#00000000"
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
android:strokeColor="#8B418F"
android:strokeLineCap="round"
android:strokeWidth="2" />
<path
android:pathData="M16,2H55C57.209,2 59,3.791 59,6V52"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M45,10H9C6.791,10 5,11.791 5,14V55.854C5,59.177 8.817,61.051 11.446,59.019L24.554,48.89C25.995,47.777 28.005,47.777 29.446,48.89L42.554,59.019C45.183,61.051 49,59.177 49,55.854V14C49,11.791 47.209,10 45,10Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startX="8"
android:startY="8"
android:endX="56"
android:endY="56"
android:type="linear">
<item android:offset="0" android:color="#FFFFA8FF"/>
<item android:offset="1" android:color="#FFFF8B5E"/>
</gradient>
</aapt:attr>
</path>
</vector>

@ -60,7 +60,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
@ -76,7 +75,7 @@ import androidx.core.view.doOnPreDraw
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel
@ -376,12 +375,11 @@ fun TopicIcon(
imageUrl: String,
modifier: Modifier = Modifier
) {
AsyncImage(
DynamicAsyncImage(
// TODO b/228077205, show loading image visual instead of static placeholder
placeholder = painterResource(R.drawable.ic_icon_placeholder),
model = imageUrl,
imageUrl = imageUrl,
contentDescription = null, // decorative
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = modifier
.padding(10.dp)
.size(32.dp)

@ -31,12 +31,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
@ -122,10 +121,9 @@ private fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier)
contentDescription = null, // decorative image
)
} else {
AsyncImage(
model = topicImageUrl,
DynamicAsyncImage(
imageUrl = topicImageUrl,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = modifier
)
}

@ -22,6 +22,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
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.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 org.junit.Rule
@ -40,7 +41,8 @@ class SettingsDialogTest {
composeTestRule.setContent {
SettingsDialog(
settingsUiState = Loading,
onDismiss = { },
onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)
@ -52,16 +54,18 @@ class SettingsDialogTest {
}
@Test
fun whenStateIsSuccess_allSettingsAreDisplayed() {
fun whenStateIsSuccess_allDefaultSettingsAreDisplayed() {
composeTestRule.setContent {
SettingsDialog(
settingsUiState = Success(
UserEditableSettings(
brand = ANDROID,
useDynamicColor = false,
darkThemeConfig = DARK
)
),
onDismiss = { },
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)
@ -81,6 +85,81 @@ class SettingsDialogTest {
composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertIsSelected()
}
@Test
fun whenStateIsSuccess_supportsDynamicColor_usesDefaultBrand_DynamicColorOptionIsDisplayed() {
composeTestRule.setContent {
SettingsDialog(
settingsUiState = Success(
UserEditableSettings(
brand = DEFAULT,
darkThemeConfig = DARK,
useDynamicColor = false,
)
),
supportDynamicColor = true,
onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)
}
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertExists()
// Check that the correct default dynamic color setting is selected.
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertIsSelected()
}
@Test
fun whenStateIsSuccess_notSupportDynamicColor_DynamicColorOptionIsNotDisplayed() {
composeTestRule.setContent {
SettingsDialog(
settingsUiState = Success(
UserEditableSettings(
brand = ANDROID,
darkThemeConfig = DARK,
useDynamicColor = false,
)
),
onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)
}
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference))
.assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist()
}
@Test
fun whenStateIsSuccess_usesAndroidBrand_DynamicColorOptionIsNotDisplayed() {
composeTestRule.setContent {
SettingsDialog(
settingsUiState = Success(
UserEditableSettings(
brand = ANDROID,
darkThemeConfig = DARK,
useDynamicColor = false,
)
),
onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)
}
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference))
.assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist()
}
@Test
fun whenStateIsSuccess_allLinksAreDisplayed() {
composeTestRule.setContent {
@ -88,10 +167,12 @@ class SettingsDialogTest {
settingsUiState = Success(
UserEditableSettings(
brand = ANDROID,
darkThemeConfig = DARK
darkThemeConfig = DARK,
useDynamicColor = false,
)
),
onDismiss = { },
onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {},
onChangeDarkThemeConfig = {}
)

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
@ -38,17 +39,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM
@ -71,19 +76,33 @@ fun SettingsDialog(
onDismiss = onDismiss,
settingsUiState = settingsUiState,
onChangeThemeBrand = viewModel::updateThemeBrand,
onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference,
onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SettingsDialog(
settingsUiState: SettingsUiState,
supportDynamicColor: Boolean = supportsDynamicTheming(),
onDismiss: () -> Unit,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit
) {
val configuration = LocalConfiguration.current
/**
* usePlatformDefaultWidth = false is use as a temporary fix to allow
* height recalculation during recomposition. This, however, causes
* Dialog's to occupy full width in Compact mode. Therefore max width
* is configured below. This should be removed when there's fix to
* https://issuetracker.google.com/issues/221643630
*/
AlertDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.widthIn(max = configuration.screenWidthDp.dp - 80.dp),
onDismissRequest = { onDismiss() },
title = {
Text(
@ -101,10 +120,13 @@ fun SettingsDialog(
modifier = Modifier.padding(vertical = 16.dp)
)
}
is Success -> {
SettingsPanel(
settings = settingsUiState.settings,
supportDynamicColor = supportDynamicColor,
onChangeThemeBrand = onChangeThemeBrand,
onChangeDynamicColorPreference = onChangeDynamicColorPreference,
onChangeDarkThemeConfig = onChangeDarkThemeConfig
)
}
@ -129,7 +151,9 @@ fun SettingsDialog(
@Composable
private fun SettingsPanel(
settings: UserEditableSettings,
supportDynamicColor: Boolean,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit
) {
SettingsDialogSectionTitle(text = stringResource(string.theme))
@ -145,7 +169,22 @@ private fun SettingsPanel(
onClick = { onChangeThemeBrand(ANDROID) }
)
}
SettingsDialogSectionTitle(text = "Dark mode preference")
if (settings.brand == DEFAULT && supportDynamicColor) {
SettingsDialogSectionTitle(text = stringResource(R.string.dynamic_color_preference))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.dynamic_color_yes),
selected = settings.useDynamicColor,
onClick = { onChangeDynamicColorPreference(true) }
)
SettingsDialogThemeChooserRow(
text = stringResource(string.dynamic_color_no),
selected = !settings.useDynamicColor,
onClick = { onChangeDynamicColorPreference(false) }
)
}
}
SettingsDialogSectionTitle(text = stringResource(R.string.dark_mode_preference))
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.dark_mode_config_system_default),
@ -262,11 +301,13 @@ private fun PreviewSettingsDialog() {
settingsUiState = Success(
UserEditableSettings(
brand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM
darkThemeConfig = FOLLOW_SYSTEM,
useDynamicColor = false
)
),
onChangeThemeBrand = { },
onChangeDarkThemeConfig = { }
onChangeThemeBrand = {},
onChangeDynamicColorPreference = {},
onChangeDarkThemeConfig = {}
)
}
}
@ -278,14 +319,17 @@ private fun PreviewSettingsDialogLoading() {
SettingsDialog(
onDismiss = {},
settingsUiState = Loading,
onChangeThemeBrand = { },
onChangeDarkThemeConfig = { }
onChangeThemeBrand = {},
onChangeDynamicColorPreference = {},
onChangeDarkThemeConfig = {}
)
}
}
/* ktlint-disable max-line-length */
private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy"
private const val LICENSES_URL = "https://github.com/android/nowinandroid/blob/main/app/LICENSES.md#open-source-licenses-and-copyright-notices"
private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines"
private const val LICENSES_URL =
"https://github.com/android/nowinandroid/blob/main/app/LICENSES.md#open-source-licenses-and-copyright-notices"
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"

@ -41,6 +41,7 @@ class SettingsViewModel @Inject constructor(
Success(
settings = UserEditableSettings(
brand = userData.themeBrand,
useDynamicColor = userData.useDynamicColor,
darkThemeConfig = userData.darkThemeConfig
)
)
@ -68,12 +69,22 @@ class SettingsViewModel @Inject constructor(
userDataRepository.setDarkThemeConfig(darkThemeConfig)
}
}
fun updateDynamicColorPreference(useDynamicColor: Boolean) {
viewModelScope.launch {
userDataRepository.setDynamicColorPreference(useDynamicColor)
}
}
}
/**
* Represents the settings which the user can edit within the app.
*/
data class UserEditableSettings(val brand: ThemeBrand, val darkThemeConfig: DarkThemeConfig)
data class UserEditableSettings(
val brand: ThemeBrand,
val useDynamicColor: Boolean,
val darkThemeConfig: DarkThemeConfig
)
sealed interface SettingsUiState {
object Loading : SettingsUiState

@ -25,8 +25,12 @@
<string name="theme">Theme</string>
<string name="brand_default">Default</string>
<string name="brand_android">Android</string>
<string name="dark_mode_preference">Dark mode preference</string>
<string name="dark_mode_config_system_default">System default</string>
<string name="dark_mode_config_light">Light</string>
<string name="dark_mode_config_dark">Dark</string>
<string name="dynamic_color_preference">Use Dynamic Color</string>
<string name="dynamic_color_yes">Yes</string>
<string name="dynamic_color_no">No</string>
<string name="dismiss_dialog_button_text">OK</string>
</resources>
</resources>

@ -63,7 +63,8 @@ class SettingsViewModelTest {
Success(
UserEditableSettings(
brand = ANDROID,
darkThemeConfig = DARK
darkThemeConfig = DARK,
useDynamicColor = false
)
),
viewModel.settingsUiState.value

@ -39,14 +39,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
@ -153,10 +152,9 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) {
Column(
modifier = Modifier.padding(horizontal = 24.dp)
) {
AsyncImage(
model = imageUrl,
DynamicAsyncImage(
imageUrl = imageUrl,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(216.dp)

Loading…
Cancel
Save