From b1b843893eb4766cbb4bd6f14fc35d1f5b81e67e Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Wed, 10 Aug 2022 11:37:48 -0700 Subject: [PATCH] Add data logic for theme switcher Change-Id: Ifffadb897de4f6e08f7115103f99c156a7098b70 --- app/build.gradle.kts | 4 + .../samples/apps/nowinandroid/MainActivity.kt | 98 ++++++++++++++++++- .../samples/apps/nowinandroid/ui/NiaApp.kt | 92 ++++++++--------- .../OfflineFirstUserDataRepository.kt | 8 ++ .../data/repository/UserDataRepository.kt | 12 +++ .../repository/fake/FakeUserDataRepository.kt | 10 ++ .../OfflineFirstUserDataRepositoryTest.kt | 58 +++++++++++ .../datastore/NiaPreferencesDataSource.kt | 43 ++++++++ .../nowinandroid/data/dark_theme_config.proto | 27 +++++ .../apps/nowinandroid/data/theme_brand.proto | 26 +++++ .../nowinandroid/data/user_preferences.proto | 6 ++ .../core/designsystem/ThemeTest.kt | 40 ++------ .../core/designsystem/component/Background.kt | 8 +- .../core/designsystem/theme/Theme.kt | 78 ++++++++------- .../core/model/data/DarkThemeConfig.kt | 21 ++++ .../core/model/data/ThemeBrand.kt | 21 ++++ .../nowinandroid/core/model/data/UserData.kt | 2 + .../repository/TestUserDataRepository.kt | 18 +++- gradle/libs.versions.toml | 1 + 19 files changed, 456 insertions(+), 117 deletions(-) create mode 100644 core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto create mode 100644 core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 10bfe9c22..d70f73eb5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,9 +83,12 @@ dependencies { implementation(project(":feature:bookmarks")) implementation(project(":feature:topic")) + implementation(project(":core:common")) implementation(project(":core:ui")) implementation(project(":core:designsystem")) implementation(project(":core:navigation")) + implementation(project(":core:data")) + implementation(project(":core:model")) implementation(project(":sync:work")) implementation(project(":sync:sync-test")) @@ -97,6 +100,7 @@ dependencies { androidTestImplementation(libs.androidx.navigation.testing) debugImplementation(libs.androidx.compose.ui.testManifest) + implementation(libs.accompanist.systemuicontroller) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index e5b86f910..fae677bab 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -19,14 +19,34 @@ package com.google.samples.apps.nowinandroid import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.result.Result +import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.ui.NiaApp import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint @@ -38,16 +58,60 @@ class MainActivity : ComponentActivity() { @Inject lateinit var lazyStats: dagger.Lazy + @Inject + lateinit var userDataRepository: UserDataRepository + override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + /** + * The current user data, updated here to drive the UI theme + */ + var userDataResult: Result by mutableStateOf(Result.Loading) + + // Update the user data + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + userDataRepository.userDataStream + .asResult() + .onEach { + userDataResult = it + } + .collect() + } + } + + // Keep the splash screen on-screen until the user data is loaded + splashScreen.setKeepOnScreenCondition { + when (userDataResult) { + Result.Loading -> true + is Result.Success, is Result.Error -> false + } + } + // Turn off the decor fitting system windows, which allows us to handle insets, // including IME animations WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - NiaApp(calculateWindowSizeClass(this)) + val systemUiController = rememberSystemUiController() + val darkTheme = shouldUseDarkTheme(userDataResult) + + // Update the dark content of the system bars to match the theme + DisposableEffect(systemUiController, darkTheme) { + systemUiController.systemBarsDarkContentEnabled = !darkTheme + onDispose {} + } + + NiaTheme( + darkTheme = darkTheme, + androidTheme = shouldUseAndroidTheme(userDataResult) + ) { + NiaApp( + windowSizeClass = calculateWindowSizeClass(this), + ) + } } } @@ -61,3 +125,33 @@ class MainActivity : ComponentActivity() { lazyStats.get().isTrackingEnabled = false } } + +/** + * Returns `true` if the Android theme shoudl be used, as a function of the [userDataResult]. + */ +@Composable +fun shouldUseAndroidTheme( + userDataResult: Result, +): Boolean = when (userDataResult) { + Result.Loading, is Result.Error -> false + is Result.Success -> when (userDataResult.data.themeBrand) { + ThemeBrand.DEFAULT -> false + ThemeBrand.ANDROID -> true + } +} + +/** + * Returns `true` if dark theme should be used, as a function of the [userDataResult] and the + * current system context. + */ +@Composable +fun shouldUseDarkTheme( + userDataResult: Result, +): Boolean = when (userDataResult) { + Result.Loading, is Result.Error -> isSystemInDarkTheme() + is Result.Success -> when (userDataResult.data.darkThemeConfig) { + DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme() + DarkThemeConfig.LIGHT -> false + DarkThemeConfig.DARK -> true + } +} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 1d5ec6e98..f55544274 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -66,58 +66,56 @@ fun NiaApp( windowSizeClass: WindowSizeClass, appState: NiaAppState = rememberNiaAppState(windowSizeClass) ) { - NiaTheme { - val background: @Composable (@Composable () -> Unit) -> Unit = - when (appState.currentDestination?.route) { - ForYouDestination.route -> { content -> NiaGradientBackground(content = content) } - else -> { content -> NiaBackground(content = content) } - } + val background: @Composable (@Composable () -> Unit) -> Unit = + when (appState.currentDestination?.route) { + ForYouDestination.route -> { content -> NiaGradientBackground(content = content) } + else -> { content -> NiaBackground(content = content) } + } - background { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - bottomBar = { - if (appState.shouldShowBottomBar) { - NiaBottomBar( - destinations = appState.topLevelDestinations, - onNavigateToDestination = appState::navigate, - currentDestination = appState.currentDestination - ) - } + background { + Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + bottomBar = { + if (appState.shouldShowBottomBar) { + NiaBottomBar( + destinations = appState.topLevelDestinations, + onNavigateToDestination = appState::navigate, + currentDestination = appState.currentDestination + ) } - ) { padding -> - Row( - Modifier - .fillMaxSize() - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal - ) - ) - ) { - if (appState.shouldShowNavRail) { - NiaNavRail( - destinations = appState.topLevelDestinations, - onNavigateToDestination = appState::navigate, - currentDestination = appState.currentDestination, - modifier = Modifier.safeDrawingPadding() + } + ) { padding -> + Row( + Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal ) - } - - NiaNavHost( - navController = appState.navController, - onBackClick = appState::onBackClick, + ) + ) { + if (appState.shouldShowNavRail) { + NiaNavRail( + destinations = appState.topLevelDestinations, onNavigateToDestination = appState::navigate, - modifier = Modifier - .padding(padding) - .consumedWindowInsets(padding) + currentDestination = appState.currentDestination, + modifier = Modifier.safeDrawingPadding() ) } + + NiaNavHost( + navController = appState.navController, + onBackClick = appState::onBackClick, + onNavigateToDestination = appState::navigate, + modifier = Modifier + .padding(padding) + .consumedWindowInsets(padding) + ) } } } @@ -148,6 +146,7 @@ private fun NiaNavRail( imageVector = icon.imageVector, contentDescription = null ) + is DrawableResourceIcon -> Icon( painter = painterResource(id = icon.id), contentDescription = null @@ -184,6 +183,7 @@ private fun NiaBottomBar( imageVector = icon.imageVector, contentDescription = null ) + is DrawableResourceIcon -> Icon( painter = painterResource(id = icon.id), contentDescription = null diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 046189037..8af0d3711 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -42,4 +44,10 @@ class OfflineFirstUserDataRepository @Inject constructor( override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) = niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) + + override suspend fun setThemeBrand(themeBrand: ThemeBrand) = + niaPreferencesDataSource.setThemeBrand(themeBrand) + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = + niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index deea011cc..7554d3f03 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.coroutines.flow.Flow @@ -50,4 +52,14 @@ interface UserDataRepository { * Updates the bookmarked status for a news resource */ suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) + + /** + * Sets the desired theme brand. + */ + suspend fun setThemeBrand(themeBrand: ThemeBrand) + + /** + * Sets the desired dark theme config. + */ + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt index 5feb5eab3..58714cf40 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt @@ -19,6 +19,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import javax.inject.Inject import kotlinx.coroutines.flow.Flow @@ -53,4 +55,12 @@ class FakeUserDataRepository @Inject constructor( override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) } + + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { + niaPreferencesDataSource.setThemeBrand(themeBrand) + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index d65820ea0..fd4b2ccde 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -18,6 +18,9 @@ package com.google.samples.apps.nowinandroid.core.data.repository import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand +import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.runTest @@ -46,6 +49,21 @@ class OfflineFirstUserDataRepositoryTest { ) } + @Test + fun offlineFirstUserDataRepository_default_user_data_is_correct() = + runTest { + assertEquals( + UserData( + bookmarkedNewsResources = emptySet(), + followedTopics = emptySet(), + followedAuthors = emptySet(), + themeBrand = ThemeBrand.DEFAULT, + darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM + ), + subject.userDataStream.first() + ) + } + @Test fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = runTest { @@ -129,4 +147,44 @@ class OfflineFirstUserDataRepositoryTest { .first() ) } + + @Test + fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = + runTest { + subject.setThemeBrand(ThemeBrand.ANDROID) + + assertEquals( + ThemeBrand.ANDROID, + subject.userDataStream + .map { it.themeBrand } + .first() + ) + assertEquals( + ThemeBrand.ANDROID, + niaPreferencesDataSource + .userDataStream + .map { it.themeBrand } + .first() + ) + } + + @Test + fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() = + runTest { + subject.setDarkThemeConfig(DarkThemeConfig.DARK) + + assertEquals( + DarkThemeConfig.DARK, + subject.userDataStream + .map { it.darkThemeConfig } + .first() + ) + assertEquals( + DarkThemeConfig.DARK, + niaPreferencesDataSource + .userDataStream + .map { it.darkThemeConfig } + .first() + ) + } } diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index 68bd599cf..52757e235 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -18,6 +18,10 @@ package com.google.samples.apps.nowinandroid.core.datastore import android.util.Log import androidx.datastore.core.DataStore +import com.google.protobuf.kotlin.DslList +import com.google.protobuf.kotlin.DslProxy +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import java.io.IOException import javax.inject.Inject @@ -34,6 +38,21 @@ class NiaPreferencesDataSource @Inject constructor( bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys, followedAuthors = it.followedAuthorIdsMap.keys, + themeBrand = when (it.themeBrand!!) { + ThemeBrandProto.THEME_BRAND_UNSPECIFIED, + ThemeBrandProto.UNRECOGNIZED, + ThemeBrandProto.THEME_BRAND_DEFAULT -> ThemeBrand.DEFAULT + ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID + }, + darkThemeConfig = when (it.darkThemeConfig!!) { + DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED, + DarkThemeConfigProto.UNRECOGNIZED, + DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM -> + DarkThemeConfig.FOLLOW_SYSTEM + DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT -> + DarkThemeConfig.LIGHT + DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK + } ) } @@ -95,6 +114,30 @@ class NiaPreferencesDataSource @Inject constructor( } } + suspend fun setThemeBrand(themeBrand: ThemeBrand) { + userPreferences.updateData { + it.copy { + this.themeBrand = when (themeBrand) { + ThemeBrand.DEFAULT -> ThemeBrandProto.THEME_BRAND_DEFAULT + ThemeBrand.ANDROID -> ThemeBrandProto.THEME_BRAND_ANDROID + } + } + } + } + + suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + userPreferences.updateData { + it.copy { + this.darkThemeConfig = when (darkThemeConfig) { + DarkThemeConfig.FOLLOW_SYSTEM -> + DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM + DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT + DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK + } + } + } + } + suspend fun toggleNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { try { userPreferences.updateData { diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto new file mode 100644 index 000000000..82bac8366 --- /dev/null +++ b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 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 + * + * http://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. + */ + +syntax = "proto3"; + +option java_package = "com.google.samples.apps.nowinandroid.core.datastore"; +option java_multiple_files = true; + +enum DarkThemeConfigProto { + DARK_THEME_CONFIG_UNSPECIFIED = 0; + DARK_THEME_CONFIG_FOLLOW_SYSTEM = 1; + DARK_THEME_CONFIG_LIGHT = 2; + DARK_THEME_CONFIG_DARK = 3; +} diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto new file mode 100644 index 000000000..8bcf5859b --- /dev/null +++ b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 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 + * + * http://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. + */ + +syntax = "proto3"; + +option java_package = "com.google.samples.apps.nowinandroid.core.datastore"; +option java_multiple_files = true; + +enum ThemeBrandProto { + THEME_BRAND_UNSPECIFIED = 0; + THEME_BRAND_DEFAULT = 1; + THEME_BRAND_ANDROID = 2; +} diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index 029502681..f0eca3d32 100644 --- a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -16,6 +16,9 @@ syntax = "proto3"; +import "com/google/samples/apps/nowinandroid/data/dark_theme_config.proto"; +import "com/google/samples/apps/nowinandroid/data/theme_brand.proto"; + option java_package = "com.google.samples.apps.nowinandroid.core.datastore"; option java_multiple_files = true; @@ -37,4 +40,7 @@ message UserPreferences { map followed_topic_ids = 13; map followed_author_ids = 14; map bookmarked_news_resource_ids = 15; + + ThemeBrandProto theme_brand = 16; + DarkThemeConfigProto dark_theme_config = 17; } diff --git a/core/designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt b/core/designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt index d0fb5ff83..8710ba265 100644 --- a/core/designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt +++ b/core/designsystem/src/androidTest/java/com/google/samples/apps/nowinandroid/core/designsystem/ThemeTest.kt @@ -58,7 +58,7 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = false, - dynamicColor = false, + disableDynamicTheming = true, androidTheme = false ) { val colorScheme = LightDefaultColorScheme @@ -79,7 +79,7 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = true, - dynamicColor = false, + disableDynamicTheming = true, androidTheme = false ) { val colorScheme = DarkDefaultColorScheme @@ -100,7 +100,6 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = false, - dynamicColor = true, androidTheme = false ) { val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -129,7 +128,6 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = true, - dynamicColor = true, androidTheme = false ) { val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -154,7 +152,7 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = false, - dynamicColor = false, + disableDynamicTheming = true, androidTheme = true ) { val colorScheme = LightAndroidColorScheme @@ -172,7 +170,7 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = true, - dynamicColor = false, + disableDynamicTheming = true, androidTheme = true ) { val colorScheme = DarkAndroidColorScheme @@ -190,25 +188,13 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = false, - dynamicColor = true, androidTheme = true ) { - val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicLightColorScheme(LocalContext.current) - } else { - LightDefaultColorScheme - } + val colorScheme = LightAndroidColorScheme assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) - val gradientColors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - GradientColors() - } else { - LightDefaultGradientColors - } + val gradientColors = GradientColors() assertEquals(gradientColors, LocalGradientColors.current) - val backgroundTheme = BackgroundTheme( - color = colorScheme.surface, - tonalElevation = 2.dp - ) + val backgroundTheme = LightAndroidBackgroundTheme assertEquals(backgroundTheme, LocalBackgroundTheme.current) } } @@ -219,21 +205,13 @@ class ThemeTest { composeTestRule.setContent { NiaTheme( darkTheme = true, - dynamicColor = true, androidTheme = true ) { - val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicDarkColorScheme(LocalContext.current) - } else { - DarkDefaultColorScheme - } + val colorScheme = DarkAndroidColorScheme assertColorSchemesEqual(colorScheme, MaterialTheme.colorScheme) val gradientColors = GradientColors() assertEquals(gradientColors, LocalGradientColors.current) - val backgroundTheme = BackgroundTheme( - color = colorScheme.surface, - tonalElevation = 2.dp - ) + val backgroundTheme = DarkAndroidBackgroundTheme assertEquals(backgroundTheme, LocalBackgroundTheme.current) } } diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt index 985471534..833ec955d 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Background.kt @@ -144,7 +144,7 @@ annotation class ThemePreviews @ThemePreviews @Composable fun BackgroundDefault() { - NiaTheme { + NiaTheme(disableDynamicTheming = true) { NiaBackground(Modifier.size(100.dp), content = {}) } } @@ -152,7 +152,7 @@ fun BackgroundDefault() { @ThemePreviews @Composable fun BackgroundDynamic() { - NiaTheme(dynamicColor = true) { + NiaTheme { NiaBackground(Modifier.size(100.dp), content = {}) } } @@ -168,7 +168,7 @@ fun BackgroundAndroid() { @ThemePreviews @Composable fun GradientBackgroundDefault() { - NiaTheme { + NiaTheme(disableDynamicTheming = true) { NiaGradientBackground(Modifier.size(100.dp), content = {}) } } @@ -176,7 +176,7 @@ fun GradientBackgroundDefault() { @ThemePreviews @Composable fun GradientBackgroundDynamic() { - NiaTheme(dynamicColor = true) { + NiaTheme { NiaGradientBackground(Modifier.size(100.dp), content = {}) } } diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt index efec108f8..10f0c3898 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Theme.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.designsystem.theme import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -173,55 +174,63 @@ val DarkAndroidBackgroundTheme = BackgroundTheme(color = Color.Black) /** * Now in Android theme. * - * The order of precedence for the color scheme is: Dynamic color > Android theme > Default theme. - * Dark theme is independent as all the aforementioned color schemes have light and dark versions. - * The default theme color scheme is used by default. - * * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). - * @param dynamicColor Whether the theme should use a dynamic color scheme (Android 12+ only). - * @param androidTheme Whether the theme should use the Android theme color scheme. + * @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(), - dynamicColor: Boolean = false, androidTheme: Boolean = false, - content: @Composable() () -> Unit + 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( + darkTheme: Boolean = isSystemInDarkTheme(), + androidTheme: Boolean = false, + disableDynamicTheming: Boolean, + content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } else { - if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme - } - } - androidTheme -> if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme - else -> if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme + val colorScheme = if (androidTheme) { + if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme + } else if (!disableDynamicTheming && supportsDynamicTheming()) { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else { + if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme } val defaultGradientColors = GradientColors() - val gradientColors = when { - dynamicColor -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - defaultGradientColors - } else { - if (darkTheme) defaultGradientColors else LightDefaultGradientColors - } - } - androidTheme -> defaultGradientColors - else -> if (darkTheme) defaultGradientColors else LightDefaultGradientColors + val gradientColors = if (androidTheme || (!disableDynamicTheming && supportsDynamicTheming())) { + defaultGradientColors + } else { + if (darkTheme) defaultGradientColors else LightDefaultGradientColors } val defaultBackgroundTheme = BackgroundTheme( color = colorScheme.surface, tonalElevation = 2.dp ) - val backgroundTheme = when { - dynamicColor -> defaultBackgroundTheme - androidTheme -> if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme - else -> defaultBackgroundTheme + val backgroundTheme = if (androidTheme) { + if (darkTheme) DarkAndroidBackgroundTheme else LightAndroidBackgroundTheme + } else { + defaultBackgroundTheme } CompositionLocalProvider( @@ -235,3 +244,6 @@ fun NiaTheme( ) } } + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt new file mode 100644 index 000000000..f130a70db --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 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.model.data + +enum class DarkThemeConfig { + FOLLOW_SYSTEM, LIGHT, DARK +} diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt new file mode 100644 index 000000000..d8953df3c --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2022 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.model.data + +enum class ThemeBrand { + DEFAULT, ANDROID +} diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt index 4535a14b4..13f1dd737 100644 --- a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt @@ -23,4 +23,6 @@ data class UserData( val bookmarkedNewsResources: Set, val followedTopics: Set, val followedAuthors: Set, + val themeBrand: ThemeBrand, + val darkThemeConfig: DarkThemeConfig, ) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index 4b0dad48d..72561b84c 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.core.testing.repository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.flow.Flow @@ -26,7 +28,9 @@ import kotlinx.coroutines.flow.filterNotNull private val emptyUserData = UserData( bookmarkedNewsResources = emptySet(), followedTopics = emptySet(), - followedAuthors = emptySet() + followedAuthors = emptySet(), + themeBrand = ThemeBrand.DEFAULT, + darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM ) class TestUserDataRepository : UserDataRepository { @@ -74,6 +78,18 @@ class TestUserDataRepository : UserDataRepository { } } + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(themeBrand = themeBrand)) + } + } + + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { + currentUserData.let { current -> + _userData.tryEmit(current.copy(darkThemeConfig = darkThemeConfig)) + } + } + /** * A test-only API to allow querying the current followed topics. */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b63d2f937..cdf65d1b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ turbine = "0.8.0" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } +accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }