diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 2989b5370..f9b63efe4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -6,8 +6,10 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.getValue import androidx.core.os.LocaleListCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @@ -37,7 +39,10 @@ class MainActivity : AppCompatActivity() { AppCompatDelegate.setApplicationLocales(localeList) } setContent { - BitwardenTheme { + val state by mainViewModel.stateFlow.collectAsStateWithLifecycle() + BitwardenTheme( + theme = state.theme, + ) { RootNavScreen( onSplashScreenRemoved = { shouldShowSplashScreen = false }, ) @@ -47,7 +52,7 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - mainViewModel.sendAction( + mainViewModel.trySendAction( action = MainAction.ReceiveNewIntent( intent = intent, ), diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 26b0154c9..56f70cd87 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -1,10 +1,18 @@ package com.x8bit.bitwarden import android.content.Intent -import androidx.lifecycle.ViewModel +import android.os.Parcelable +import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject /** @@ -13,18 +21,32 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( private val authRepository: AuthRepository, -) : ViewModel() { - /** - * Send a [MainAction]. - */ - fun sendAction(action: MainAction) { + settingsRepository: SettingsRepository, +) : BaseViewModel<MainState, Unit, MainAction>( + MainState( + theme = settingsRepository.appTheme, + ), +) { + init { + settingsRepository + .appThemeStateFlow + .onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) } + .launchIn(viewModelScope) + } + + override fun handleAction(action: MainAction) { when (action) { - is MainAction.ReceiveNewIntent -> handleNewIntentReceived(intent = action.intent) + is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action) + is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action) } } - private fun handleNewIntentReceived(intent: Intent) { - val captchaCallbackTokenResult = intent.getCaptchaCallbackTokenResult() + private fun handleAppThemeUpdated(action: MainAction.Internal.ThemeUpdate) { + mutableStateFlow.update { it.copy(theme = action.theme) } + } + + private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) { + val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult() when { captchaCallbackTokenResult != null -> { authRepository.setCaptchaCallbackTokenResult( @@ -37,6 +59,14 @@ class MainViewModel @Inject constructor( } } +/** + * Models state for the [MainActivity]. + */ +@Parcelize +data class MainState( + val theme: AppTheme, +) : Parcelable + /** * Models actions for the [MainActivity]. */ @@ -45,4 +75,16 @@ sealed class MainAction { * Receive Intent by the application. */ data class ReceiveNewIntent(val intent: Intent) : MainAction() + + /** + * Actions for internal use by the ViewModel. + */ + sealed class Internal : MainAction() { + /** + * Indicates that the app theme has changed. + */ + data class ThemeUpdate( + val theme: AppTheme, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index a7b1af1cd..4d5dab6a8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -2,11 +2,13 @@ package com.x8bit.bitwarden.data.platform.datasource.disk import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.flow.Flow /** * Primary access point for general settings-related disk information. */ +@Suppress("TooManyFunctions") interface SettingsDiskSource { /** @@ -14,6 +16,16 @@ interface SettingsDiskSource { */ var appLanguage: AppLanguage? + /** + * The currently persisted app theme (or `null` if not set). + */ + var appTheme: AppTheme + + /** + * Emits updates that track [appTheme]. + */ + val appThemeFlow: Flow<AppTheme> + /** * The currently persisted setting for getting login item icons (or `null` if not set). */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index e86c4c636..a158ba039 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -5,11 +5,13 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companio import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onSubscription private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale" +private const val APP_THEME_KEY = "$BASE_KEY:theme" private const val PULL_TO_REFRESH_KEY = "$BASE_KEY:syncOnRefresh" private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction" private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout" @@ -23,6 +25,9 @@ class SettingsDiskSourceImpl( val sharedPreferences: SharedPreferences, ) : BaseDiskSource(sharedPreferences = sharedPreferences), SettingsDiskSource { + private val mutableAppThemeFlow = + bufferedMutableSharedFlow<AppTheme>(replay = 1) + private val mutableVaultTimeoutActionFlowMap = mutableMapOf<String, MutableSharedFlow<VaultTimeoutAction?>>() @@ -47,6 +52,24 @@ class SettingsDiskSourceImpl( ) } + override var appTheme: AppTheme + get() = getString(key = APP_THEME_KEY) + ?.let { storedValue -> + AppTheme.entries.firstOrNull { storedValue == it.value } + } + ?: AppTheme.DEFAULT + set(newValue) { + putString( + key = APP_THEME_KEY, + value = newValue.value, + ) + mutableAppThemeFlow.tryEmit(appTheme) + } + + override val appThemeFlow: Flow<AppTheme> + get() = mutableAppThemeFlow + .onSubscription { emit(appTheme) } + override var isIconLoadingDisabled: Boolean? get() = getBoolean(key = DISABLE_ICON_LOADING_KEY) set(value) { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 7f99408ce..9f532d6a4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -16,6 +17,16 @@ interface SettingsRepository { */ var appLanguage: AppLanguage + /** + * The currently stored [AppTheme]. + */ + var appTheme: AppTheme + + /** + * Tracks changes to the [AppTheme]. + */ + val appThemeStateFlow: StateFlow<AppTheme> + /** * The current setting for getting login item icons. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 3ad8136c8..d9fffebcf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -35,6 +36,17 @@ class SettingsRepositoryImpl( settingsDiskSource.appLanguage = value } + override var appTheme: AppTheme by settingsDiskSource::appTheme + + override val appThemeStateFlow: StateFlow<AppTheme> + get() = settingsDiskSource + .appThemeFlow + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource.appTheme, + ) + override var isIconLoadingDisabled: Boolean get() = settingsDiskSource.isIconLoadingDisabled ?: false set(value) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt index c512359cc..ab03a9790 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPlaceholderAccountItem.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** @@ -42,7 +43,7 @@ fun BitwardenPlaceholderAccountActionItem( @Preview @Composable private fun BitwardenPlaceholderAccountActionItem_preview_light() { - BitwardenTheme(darkTheme = false) { + BitwardenTheme(theme = AppTheme.LIGHT) { BitwardenPlaceholderAccountActionItem( onClick = {}, ) @@ -52,7 +53,7 @@ private fun BitwardenPlaceholderAccountActionItem_preview_light() { @Preview @Composable private fun BitwardenPlaceholderAccountActionItem_preview_dark() { - BitwardenTheme(darkTheme = true) { + BitwardenTheme(theme = AppTheme.DARK) { BitwardenPlaceholderAccountActionItem( onClick = {}, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt index 5e6b0ef89..8723c711d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreen.kt @@ -36,6 +36,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import com.x8bit.bitwarden.ui.platform.util.displayLabel /** * Displays the appearance screen. @@ -161,8 +163,8 @@ private fun LanguageSelectionRow( @Composable private fun ThemeSelectionRow( - currentSelection: AppearanceState.Theme, - onThemeSelection: (AppearanceState.Theme) -> Unit, + currentSelection: AppTheme, + onThemeSelection: (AppTheme) -> Unit, modifier: Modifier = Modifier, ) { var shouldShowThemeSelectionDialog by remember { mutableStateOf(false) } @@ -174,7 +176,7 @@ private fun ThemeSelectionRow( modifier = modifier, ) { Text( - text = currentSelection.text(), + text = currentSelection.displayLabel(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -185,14 +187,14 @@ private fun ThemeSelectionRow( title = stringResource(id = R.string.theme), onDismissRequest = { shouldShowThemeSelectionDialog = false }, ) { - AppearanceState.Theme.entries.forEach { option -> + AppTheme.entries.forEach { option -> BitwardenSelectionRow( - text = option.text, + text = option.displayLabel, isSelected = option == currentSelection, onClick = { shouldShowThemeSelectionDialog = false onThemeSelection( - AppearanceState.Theme.entries.first { it == option }, + AppTheme.entries.first { it == option }, ) }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt index bb6709979..cb61078c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModel.kt @@ -4,12 +4,10 @@ import android.os.Parcelable import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.SavedStateHandle -import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel -import com.x8bit.bitwarden.ui.platform.base.util.Text -import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize @@ -29,7 +27,7 @@ class AppearanceViewModel @Inject constructor( ?: AppearanceState( language = settingsRepository.appLanguage, showWebsiteIcons = !settingsRepository.isIconLoadingDisabled, - theme = AppearanceState.Theme.DEFAULT, + theme = settingsRepository.appTheme, ), ) { override fun handleAction(action: AppearanceAction): Unit = when (action) { @@ -63,8 +61,8 @@ class AppearanceViewModel @Inject constructor( } private fun handleThemeChanged(action: AppearanceAction.ThemeChange) { - // TODO: BIT-1327 add theme support mutableStateFlow.update { it.copy(theme = action.theme) } + settingsRepository.appTheme = action.theme } } @@ -75,17 +73,8 @@ class AppearanceViewModel @Inject constructor( data class AppearanceState( val language: AppLanguage, val showWebsiteIcons: Boolean, - val theme: Theme, -) : Parcelable { - /** - * Represents the theme options the user can set. - */ - enum class Theme(val text: Text) { - DEFAULT(text = R.string.default_system.asText()), - DARK(text = R.string.dark.asText()), - LIGHT(text = R.string.light.asText()), - } -} + val theme: AppTheme, +) : Parcelable /** * Models events for the appearance screen. @@ -124,6 +113,6 @@ sealed class AppearanceAction { * Indicates that the user selected a new theme. */ data class ThemeChange( - val theme: AppearanceState.Theme, + val theme: AppTheme, ) : AppearanceAction() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt new file mode 100644 index 000000000..b9e88ebc4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/model/AppTheme.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model + +/** + * Represents the theme options the user can set. + * + * The [value] is used for consistent storage purposes. + */ +enum class AppTheme(val value: String?) { + DEFAULT(value = null), + DARK(value = "dark"), + LIGHT(value = "light"), +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index bb65c0142..6a3a2a8c3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -24,19 +24,25 @@ import androidx.core.view.WindowCompat import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManagerImpl /** - * The overall application theme. This can be configured to support a [darkTheme] and - * [dynamicColor]. + * The overall application theme. This can be configured to support a [theme] and [dynamicColor]. */ @Composable fun BitwardenTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + theme: AppTheme = AppTheme.DEFAULT, dynamicColor: Boolean = false, content: @Composable () -> Unit, ) { + val darkTheme = when (theme) { + AppTheme.DEFAULT -> isSystemInDarkTheme() + AppTheme.DARK -> true + AppTheme.LIGHT -> false + } + // Get the current scheme val context = LocalContext.current val colorScheme = when { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt new file mode 100644 index 000000000..3dde99f1f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensions.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.ui.platform.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme + +/** + * Returns a human-readable display label for the given [AppTheme]. + */ +val AppTheme.displayLabel: Text + get() = when (this) { + AppTheme.DEFAULT -> R.string.default_system.asText() + AppTheme.DARK -> R.string.dark.asText() + AppTheme.LIGHT -> R.string.light.asText() + } diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index da6c70f91..2e02117bd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -2,48 +2,78 @@ package com.x8bit.bitwarden import android.content.Intent import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import io.mockk.every +import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.runs import io.mockk.verify -import org.junit.Test -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test -class MainViewModelTest { +class MainViewModelTest : BaseViewModelTest() { - @BeforeEach - fun setUp() { - mockkStatic(LOGIN_RESULT_PATH) + private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT) + private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE) + val authRepository = mockk<AuthRepository> { + every { userStateFlow } returns mutableUserStateFlow + every { activeUserId } returns USER_ID + every { + setCaptchaCallbackTokenResult( + tokenResult = CaptchaCallbackTokenResult.Success( + token = "mockk_token", + ), + ) + } just runs } - - @AfterEach - fun tearDown() { - unmockkStatic(LOGIN_RESULT_PATH) + private val settingsRepository = mockk<SettingsRepository> { + every { appTheme } returns AppTheme.DEFAULT + every { appThemeStateFlow } returns mutableAppThemeFlow } @Test - fun `ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() { - val authRepository = mockk<AuthRepository> { - every { - setCaptchaCallbackTokenResult( - tokenResult = CaptchaCallbackTokenResult.Success( - token = "mockk_token", - ), - ) - } returns Unit + fun `on AppThemeChanged should update state`() { + val viewModel = createViewModel() + + assertEquals( + MainState( + theme = AppTheme.DEFAULT, + ), + viewModel.stateFlow.value, + ) + viewModel.trySendAction( + MainAction.Internal.ThemeUpdate( + theme = AppTheme.DARK, + ), + ) + assertEquals( + MainState( + theme = AppTheme.DARK, + ), + viewModel.stateFlow.value, + ) + + verify { + settingsRepository.appTheme + settingsRepository.appThemeStateFlow } + } + + @Test + fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() { + val viewModel = createViewModel() val mockIntent = mockk<Intent> { every { data?.host } returns "captcha-callback" every { data?.getQueryParameter("token") } returns "mockk_token" every { action } returns Intent.ACTION_VIEW } - val viewModel = MainViewModel( - authRepository = authRepository, - ) - viewModel.sendAction( + viewModel.trySendAction( MainAction.ReceiveNewIntent( intent = mockIntent, ), @@ -57,8 +87,28 @@ class MainViewModelTest { } } + private fun createViewModel() = MainViewModel( + authRepository = authRepository, + settingsRepository = settingsRepository, + ) + companion object { - private const val LOGIN_RESULT_PATH = - "com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt" + private const val USER_ID = "userID" + private val DEFAULT_USER_STATE = UserState( + activeUserId = USER_ID, + accounts = listOf( + UserState.Account( + userId = USER_ID, + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + organizations = emptyList(), + ), + ), + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt index 1dbc06224..22a0964d4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/util/ColorExtensionsTest.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.isLightOverlayRequired import com.x8bit.bitwarden.ui.platform.base.util.toSafeOverlayColor +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -25,7 +26,7 @@ class ColorExtensionsTest : BaseComposeTest() { @Test fun `toSafeOverlayColor for a dark color in light mode should use the surface color`() = - runTestWithTheme(isDarkTheme = false) { + runTestWithTheme(theme = AppTheme.LIGHT) { assertEquals( MaterialTheme.colorScheme.surface, Color.Blue.toSafeOverlayColor(), @@ -34,7 +35,7 @@ class ColorExtensionsTest : BaseComposeTest() { @Test fun `toSafeOverlayColor for a dark color in dark mode should use the onSurface color`() = - runTestWithTheme(isDarkTheme = true) { + runTestWithTheme(theme = AppTheme.DARK) { assertEquals( MaterialTheme.colorScheme.onSurface, Color.Blue.toSafeOverlayColor(), @@ -43,7 +44,7 @@ class ColorExtensionsTest : BaseComposeTest() { @Test fun `toSafeOverlayColor for a light color in light mode should use the onSurface color`() = - runTestWithTheme(isDarkTheme = false) { + runTestWithTheme(theme = AppTheme.LIGHT) { assertEquals( MaterialTheme.colorScheme.onSurface, Color.Yellow.toSafeOverlayColor(), @@ -52,7 +53,7 @@ class ColorExtensionsTest : BaseComposeTest() { @Test fun `toSafeOverlayColor for a light color in dark mode should use the surface color`() = - runTestWithTheme(isDarkTheme = true) { + runTestWithTheme(theme = AppTheme.DARK) { assertEquals( MaterialTheme.colorScheme.surface, Color.Yellow.toSafeOverlayColor(), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 9e3eda631..fa14da21b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -106,6 +107,70 @@ class SettingsDiskSourceTest { ) } + @Test + fun `appTheme when values are present should pull from SharedPreferences`() { + val appThemeBaseKey = "bwPreferencesStorage:appTheme" + val appTheme = AppTheme.DEFAULT + fakeSharedPreferences + .edit { + putString( + appThemeBaseKey, + appTheme.value, + ) + } + val actual = settingsDiskSource.appTheme + assertEquals( + appTheme, + actual, + ) + } + + @Test + fun `appTheme when values are absent should return DEFAULT`() { + assertEquals( + AppTheme.DEFAULT, + settingsDiskSource.appTheme, + ) + } + + @Test + fun `getAppThemeFlow should react to changes in getAppTheme`() = runTest { + val appTheme = AppTheme.DARK + settingsDiskSource.appThemeFlow.test { + // The initial values of the Flow and the property are in sync + assertEquals( + AppTheme.DEFAULT, + settingsDiskSource.appTheme, + ) + assertEquals( + AppTheme.DEFAULT, + awaitItem(), + ) + + // Updating the repository updates shared preferences + settingsDiskSource.appTheme = appTheme + assertEquals( + appTheme, + awaitItem(), + ) + } + } + + @Test + fun `storeAppTheme for should update SharedPreferences`() { + val appThemeBaseKey = "bwPreferencesStorage:theme" + val appTheme = AppTheme.DARK + settingsDiskSource.appTheme = appTheme + val actual = fakeSharedPreferences.getString( + appThemeBaseKey, + null, + ) + assertEquals( + appTheme.value, + actual, + ) + } + @Test fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() { val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 4ff9679ce..769b0815c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onSubscription @@ -13,6 +14,9 @@ import kotlinx.coroutines.flow.onSubscription */ class FakeSettingsDiskSource : SettingsDiskSource { + private val mutableAppThemeFlow = + bufferedMutableSharedFlow<AppTheme>(replay = 1) + private val mutableVaultTimeoutActionsFlowMap = mutableMapOf<String, MutableSharedFlow<VaultTimeoutAction?>>() @@ -25,6 +29,7 @@ class FakeSettingsDiskSource : SettingsDiskSource { private val mutableIsIconLoadingDisabled = bufferedMutableSharedFlow<Boolean?>() + private var storedAppTheme: AppTheme = AppTheme.DEFAULT private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>() private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>() @@ -34,6 +39,18 @@ class FakeSettingsDiskSource : SettingsDiskSource { override var appLanguage: AppLanguage? = null + override var appTheme: AppTheme + get() = storedAppTheme + set(value) { + storedAppTheme = value + mutableAppThemeFlow.tryEmit(value) + } + + override val appThemeFlow: Flow<AppTheme> + get() = mutableAppThemeFlow.onSubscription { + emit(appTheme) + } + override var isIconLoadingDisabled: Boolean? get() = storedIsIconLoadingDisabled set(value) { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index ed8d9fff2..058f29f68 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -130,6 +131,57 @@ class SettingsRepositoryTest { assertFalse(fakeSettingsDiskSource.isIconLoadingDisabled!!) } + @Test + fun `appTheme should pull from and update SettingsDiskSource`() { + fakeAuthDiskSource.userState = null + assertEquals( + AppTheme.DEFAULT, + settingsRepository.appTheme, + ) + + fakeAuthDiskSource.userState = MOCK_USER_STATE + + // Updates to the disk source change the repository value + fakeSettingsDiskSource.appTheme = AppTheme.DARK + assertEquals( + AppTheme.DARK, + settingsRepository.appTheme, + ) + + // Updates to the repository value change the disk source + settingsRepository.appTheme = AppTheme.LIGHT + assertEquals( + AppTheme.LIGHT, + fakeSettingsDiskSource.appTheme, + ) + } + + @Test + fun `getAppThemeFlow should react to changes in SettingsDiskSource`() = runTest { + settingsRepository + .appThemeStateFlow + .test { + assertEquals( + AppTheme.DEFAULT, + awaitItem(), + ) + fakeSettingsDiskSource.appTheme = AppTheme.DARK + assertEquals( + AppTheme.DARK, + awaitItem(), + ) + } + } + + @Test + fun `storeAppTheme should properly update SettingsDiskSource`() { + settingsRepository.appTheme = AppTheme.DARK + assertEquals( + AppTheme.DARK, + fakeSettingsDiskSource.appTheme, + ) + } + @Test fun `vaultTimeout should pull from and update SettingsDiskSource for the current user`() { fakeAuthDiskSource.userState = null diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt index db99083f2..16f094e9d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.base import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.createComposeRule +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import org.junit.Rule @@ -18,12 +19,12 @@ abstract class BaseComposeTest : BaseRobolectricTest() { * with the [BitwardenTheme]. */ protected fun runTestWithTheme( - isDarkTheme: Boolean, + theme: AppTheme, test: @Composable () -> Unit, ) { composeTestRule.setContent { BitwardenTheme( - darkTheme = isDarkTheme, + theme = theme, ) { test() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt index cf5e8c2fb..47bd196ab 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every import io.mockk.mockk @@ -121,7 +122,7 @@ class AppearanceScreenTest : BaseComposeTest() { verify { viewModel.trySendAction( AppearanceAction.ThemeChange( - theme = AppearanceState.Theme.DARK, + theme = AppTheme.DARK, ), ) } @@ -153,5 +154,5 @@ class AppearanceScreenTest : BaseComposeTest() { private val DEFAULT_STATE = AppearanceState( language = AppLanguage.DEFAULT, showWebsiteIcons = false, - theme = AppearanceState.Theme.DEFAULT, + theme = AppTheme.DEFAULT, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt index ff5078c45..c26ab608e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/appearance/AppearanceViewModelTest.kt @@ -6,6 +6,7 @@ import app.cash.turbine.test import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -22,9 +23,11 @@ import org.junit.jupiter.api.Test class AppearanceViewModelTest : BaseViewModelTest() { private val mockSettingsRepository = mockk<SettingsRepository> { every { appLanguage } returns AppLanguage.DEFAULT + every { appTheme } returns AppTheme.DEFAULT every { appLanguage = AppLanguage.ENGLISH } just runs every { isIconLoadingDisabled } returns false every { isIconLoadingDisabled = true } just runs + every { appTheme = AppTheme.DARK } just runs } @BeforeEach @@ -45,7 +48,7 @@ class AppearanceViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when set`() { - val state = DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK) + val state = DEFAULT_STATE.copy(theme = AppTheme.DARK) val viewModel = createViewModel(state = state) assertEquals(state, viewModel.stateFlow.value) } @@ -110,18 +113,24 @@ class AppearanceViewModelTest : BaseViewModelTest() { } @Test - fun `on ThemeChange should update state`() = runTest { + fun `on ThemeChange should update state and set theme in SettingsRepository`() = runTest { val viewModel = createViewModel() viewModel.stateFlow.test { assertEquals( DEFAULT_STATE, awaitItem(), ) - viewModel.trySendAction(AppearanceAction.ThemeChange(AppearanceState.Theme.DARK)) + + viewModel.trySendAction(AppearanceAction.ThemeChange(AppTheme.DARK)) assertEquals( - DEFAULT_STATE.copy(theme = AppearanceState.Theme.DARK), + DEFAULT_STATE.copy(theme = AppTheme.DARK), awaitItem(), ) + + verify { + mockSettingsRepository.appTheme + mockSettingsRepository.appTheme = AppTheme.DARK + } } } @@ -139,7 +148,7 @@ class AppearanceViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE = AppearanceState( language = AppLanguage.DEFAULT, showWebsiteIcons = true, - theme = AppearanceState.Theme.DEFAULT, + theme = AppTheme.DEFAULT, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt new file mode 100644 index 000000000..708725621 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/AppThemeExtensionsTest.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.ui.platform.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AppThemeExtensionsTest { + @Test + fun `displayLabel should return the correct value for each type`() { + mapOf( + AppTheme.DEFAULT to R.string.default_system.asText(), + AppTheme.DARK to R.string.dark.asText(), + AppTheme.LIGHT to R.string.light.asText(), + ) + .forEach { (type, label) -> + assertEquals( + label, + type.displayLabel, + ) + } + } +}