Add dynamic color option in settings for API >=32

Change-Id: Ib42c1cbd0208e98ef7610a1c8aca5d721aa464e6
pull/542/head
Angie Sasmita 1 year ago
parent eb6dbaac0b
commit 6046f6e943

@ -106,7 +106,8 @@ class MainActivity : ComponentActivity() {
NiaTheme( NiaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState) androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState)
) { ) {
NiaApp( NiaApp(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
@ -141,6 +142,17 @@ private fun shouldUseAndroidTheme(
} }
} }
/**
* Returns `true` if the dynamic color is disabled, as a function of the [uiState].
*/
@Composable
private fun shouldDisableDynamicTheming(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
Loading -> false
is Success -> !uiState.userData.useDynamicColor
}
/** /**
* Returns `true` if dark theme should be used, as a function of the [uiState] and the * Returns `true` if dark theme should be used, as a function of the [uiState] and the
* current system context. * current system context.

@ -45,6 +45,9 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) =
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) =
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

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

@ -55,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
} }
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

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

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

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

@ -77,4 +77,15 @@ class NiaPreferencesDataSourceTest {
// Then: onboarding should be shown again // Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
@Test
fun shouldUseDynamicColorFalseByDefault() = runTest {
assertFalse(subject.userData.first().useDynamicColor)
}
@Test
fun userShouldUseDynamicColorIsTrueWhenSet() = runTest {
subject.setDynamicColorPreference(true)
assertTrue(subject.userData.first().useDynamicColor)
}
} }

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

@ -38,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.LightDefaultColorScheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalBackgroundTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.designsystem.theme.TintTheme
import kotlin.test.assertEquals import kotlin.test.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -70,6 +72,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -88,6 +92,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -97,6 +103,7 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
disableDynamicTheming = false,
androidTheme = false androidTheme = false
) { ) {
val colorScheme = dynamicLightColorSchemeWithFallback() val colorScheme = dynamicLightColorSchemeWithFallback()
@ -105,6 +112,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintTheme(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -114,6 +123,7 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
disableDynamicTheming = false,
androidTheme = false androidTheme = false
) { ) {
val colorScheme = dynamicDarkColorSchemeWithFallback() val colorScheme = dynamicDarkColorSchemeWithFallback()
@ -122,6 +132,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = defaultBackgroundTheme(colorScheme) val backgroundTheme = defaultBackgroundTheme(colorScheme)
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = dynamicTintTheme(colorScheme)
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -140,6 +152,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -158,6 +172,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -167,6 +183,7 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = false, darkTheme = false,
disableDynamicTheming = false,
androidTheme = true androidTheme = true
) { ) {
val colorScheme = LightAndroidColorScheme val colorScheme = LightAndroidColorScheme
@ -175,6 +192,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = LightAndroidBackgroundTheme val backgroundTheme = LightAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -184,6 +203,7 @@ class ThemeTest {
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme( NiaTheme(
darkTheme = true, darkTheme = true,
disableDynamicTheming = false,
androidTheme = true androidTheme = true
) { ) {
val colorScheme = DarkAndroidColorScheme val colorScheme = DarkAndroidColorScheme
@ -192,6 +212,8 @@ class ThemeTest {
assertEquals(gradientColors, LocalGradientColors.current) assertEquals(gradientColors, LocalGradientColors.current)
val backgroundTheme = DarkAndroidBackgroundTheme val backgroundTheme = DarkAndroidBackgroundTheme
assertEquals(backgroundTheme, LocalBackgroundTheme.current) assertEquals(backgroundTheme, LocalBackgroundTheme.current)
val tintTheme = defaultTintTheme()
assertEquals(tintTheme, LocalTintTheme.current)
} }
} }
} }
@ -241,6 +263,18 @@ class ThemeTest {
) )
} }
private fun defaultTintTheme(): TintTheme {
return TintTheme()
}
private fun dynamicTintTheme(colorScheme: ColorScheme): TintTheme {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
TintTheme(colorScheme.primary)
} else {
TintTheme()
}
}
/** /**
* Workaround for the fact that the NiA design system specify all color scheme values. * Workaround for the fact that the NiA design system specify all color scheme values.
*/ */

@ -158,7 +158,7 @@ fun BackgroundDefault() {
@ThemePreviews @ThemePreviews
@Composable @Composable
fun BackgroundDynamic() { fun BackgroundDynamic() {
NiaTheme { NiaTheme(disableDynamicTheming = false) {
NiaBackground(Modifier.size(100.dp), content = {}) NiaBackground(Modifier.size(100.dp), content = {})
} }
} }
@ -182,7 +182,7 @@ fun GradientBackgroundDefault() {
@ThemePreviews @ThemePreviews
@Composable @Composable
fun GradientBackgroundDynamic() { fun GradientBackgroundDynamic() {
NiaTheme { NiaTheme(disableDynamicTheming = false) {
NiaGradientBackground(Modifier.size(100.dp), content = {}) NiaGradientBackground(Modifier.size(100.dp), content = {})
} }
} }

@ -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 darkTheme Whether the theme should use a dark color scheme (follows system by default).
* @param androidTheme Whether the theme should use the Android theme color scheme instead of the * @param androidTheme Whether the theme should use the Android theme color scheme instead of the
* default theme. If this is `false`, then dynamic theming will be used when supported.
*/
@Composable
fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false,
content: @Composable () -> Unit
) = NiaTheme(
darkTheme = darkTheme,
androidTheme = androidTheme,
disableDynamicTheming = false,
content = content
)
/**
* Now in Android theme. This is an internal only version, to allow disabling dynamic theming
* in tests.
*
* @param darkTheme Whether the theme should use a dark color scheme (follows system by default).
* @param androidTheme Whether the theme should use the Android theme color scheme instead of the
* default theme. * default theme.
* @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is
* supported. This parameter has no effect if [androidTheme] is `true`. * supported. This parameter has no effect if [androidTheme] is `true`.
*/ */
@Composable @Composable
internal fun NiaTheme( fun NiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
androidTheme: Boolean = false, androidTheme: Boolean = false,
disableDynamicTheming: Boolean, disableDynamicTheming: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// Color scheme // Color scheme
@ -223,6 +203,7 @@ internal fun NiaTheme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme
} }
// Gradient colors // Gradient colors
@ -246,10 +227,16 @@ internal fun NiaTheme(
androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme
else -> defaultBackgroundTheme else -> defaultBackgroundTheme
} }
val tintTheme = when {
androidTheme -> TintTheme()
!disableDynamicTheming && supportsDynamicTheming() -> TintTheme(colorScheme.primary)
else -> TintTheme()
}
// Composition locals // Composition locals
CompositionLocalProvider( CompositionLocalProvider(
LocalGradientColors provides gradientColors, LocalGradientColors provides gradientColors,
LocalBackgroundTheme provides backgroundTheme LocalBackgroundTheme provides backgroundTheme,
LocalTintTheme provides tintTheme
) { ) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
@ -260,4 +247,4 @@ internal fun NiaTheme(
} }
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

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

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

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

@ -30,6 +30,7 @@ val emptyUserData = UserData(
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = 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) { override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
currentUserData.let { current -> currentUserData.let { current ->
_userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding)) _userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding))

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

@ -31,12 +31,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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 contentDescription = null, // decorative image
) )
} else { } else {
AsyncImage( DynamicAsyncImage(
model = topicImageUrl, imageUrl = topicImageUrl,
contentDescription = null, contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
modifier = modifier modifier = modifier
) )
} }

@ -22,6 +22,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText 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.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID 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.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import org.junit.Rule import org.junit.Rule
@ -40,7 +41,8 @@ class SettingsDialogTest {
composeTestRule.setContent { composeTestRule.setContent {
SettingsDialog( SettingsDialog(
settingsUiState = Loading, settingsUiState = Loading,
onDismiss = { }, onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {}, onChangeThemeBrand = {},
onChangeDarkThemeConfig = {} onChangeDarkThemeConfig = {}
) )
@ -52,16 +54,18 @@ class SettingsDialogTest {
} }
@Test @Test
fun whenStateIsSuccess_allSettingsAreDisplayed() { fun whenStateIsSuccess_allDefaultSettingsAreDisplayed() {
composeTestRule.setContent { composeTestRule.setContent {
SettingsDialog( SettingsDialog(
settingsUiState = Success( settingsUiState = Success(
UserEditableSettings( UserEditableSettings(
brand = ANDROID, brand = ANDROID,
useDynamicColor = false,
darkThemeConfig = DARK darkThemeConfig = DARK
) )
), ),
onDismiss = { }, onDismiss = { },
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {}, onChangeThemeBrand = {},
onChangeDarkThemeConfig = {} onChangeDarkThemeConfig = {}
) )
@ -81,6 +85,79 @@ class SettingsDialogTest {
composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertIsSelected() 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 @Test
fun whenStateIsSuccess_allLinksAreDisplayed() { fun whenStateIsSuccess_allLinksAreDisplayed() {
composeTestRule.setContent { composeTestRule.setContent {
@ -88,10 +165,12 @@ class SettingsDialogTest {
settingsUiState = Success( settingsUiState = Success(
UserEditableSettings( UserEditableSettings(
brand = ANDROID, brand = ANDROID,
darkThemeConfig = DARK darkThemeConfig = DARK,
useDynamicColor = false,
) )
), ),
onDismiss = { }, onDismiss = {},
onChangeDynamicColorPreference = {},
onChangeThemeBrand = {}, onChangeThemeBrand = {},
onChangeDarkThemeConfig = {} onChangeDarkThemeConfig = {}
) )

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
@ -38,17 +39,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM
@ -71,19 +76,33 @@ fun SettingsDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
settingsUiState = settingsUiState, settingsUiState = settingsUiState,
onChangeThemeBrand = viewModel::updateThemeBrand, onChangeThemeBrand = viewModel::updateThemeBrand,
onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference,
onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig, onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,
) )
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun SettingsDialog( fun SettingsDialog(
settingsUiState: SettingsUiState, settingsUiState: SettingsUiState,
supportDynamicColor: Boolean = supportsDynamicTheming(),
onDismiss: () -> Unit, onDismiss: () -> Unit,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> 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
* b/221643630
*/
AlertDialog( AlertDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.widthIn(max = configuration.screenWidthDp.dp - 80.dp),
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
title = { title = {
Text( Text(
@ -101,10 +120,13 @@ fun SettingsDialog(
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) )
} }
is Success -> { is Success -> {
SettingsPanel( SettingsPanel(
settings = settingsUiState.settings, settings = settingsUiState.settings,
supportDynamicColor = supportDynamicColor,
onChangeThemeBrand = onChangeThemeBrand, onChangeThemeBrand = onChangeThemeBrand,
onChangeDynamicColorPreference = onChangeDynamicColorPreference,
onChangeDarkThemeConfig = onChangeDarkThemeConfig onChangeDarkThemeConfig = onChangeDarkThemeConfig
) )
} }
@ -129,7 +151,9 @@ fun SettingsDialog(
@Composable @Composable
private fun SettingsPanel( private fun SettingsPanel(
settings: UserEditableSettings, settings: UserEditableSettings,
supportDynamicColor: Boolean,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit, onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit
) { ) {
SettingsDialogSectionTitle(text = stringResource(string.theme)) SettingsDialogSectionTitle(text = stringResource(string.theme))
@ -145,7 +169,22 @@ private fun SettingsPanel(
onClick = { onChangeThemeBrand(ANDROID) } 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 == true,
onClick = { onChangeDynamicColorPreference(true) }
)
SettingsDialogThemeChooserRow(
text = stringResource(string.dynamic_color_no),
selected = settings.useDynamicColor == false,
onClick = { onChangeDynamicColorPreference(false) }
)
}
}
SettingsDialogSectionTitle(text = stringResource(R.string.dark_mode_preference))
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow( SettingsDialogThemeChooserRow(
text = stringResource(string.dark_mode_config_system_default), text = stringResource(string.dark_mode_config_system_default),
@ -262,11 +301,13 @@ private fun PreviewSettingsDialog() {
settingsUiState = Success( settingsUiState = Success(
UserEditableSettings( UserEditableSettings(
brand = DEFAULT, brand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM darkThemeConfig = FOLLOW_SYSTEM,
useDynamicColor = false
) )
), ),
onChangeThemeBrand = { }, onChangeThemeBrand = {},
onChangeDarkThemeConfig = { } onChangeDynamicColorPreference = {},
onChangeDarkThemeConfig = {}
) )
} }
} }
@ -278,14 +319,17 @@ private fun PreviewSettingsDialogLoading() {
SettingsDialog( SettingsDialog(
onDismiss = {}, onDismiss = {},
settingsUiState = Loading, settingsUiState = Loading,
onChangeThemeBrand = { }, onChangeThemeBrand = {},
onChangeDarkThemeConfig = { } onChangeDynamicColorPreference = {},
onChangeDarkThemeConfig = {}
) )
} }
} }
/* ktlint-disable max-line-length */ /* ktlint-disable max-line-length */
private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy" 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 LICENSES_URL =
private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines" "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" private const val FEEDBACK_URL = "https://goo.gle/nia-app-feedback"

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

@ -25,8 +25,12 @@
<string name="theme">Theme</string> <string name="theme">Theme</string>
<string name="brand_default">Default</string> <string name="brand_default">Default</string>
<string name="brand_android">Android</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_system_default">System default</string>
<string name="dark_mode_config_light">Light</string> <string name="dark_mode_config_light">Light</string>
<string name="dark_mode_config_dark">Dark</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> <string name="dismiss_dialog_button_text">OK</string>
</resources> </resources>

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

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

Loading…
Cancel
Save