mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 10:48:47 +03:00
BIT-1327: Add support for theme selection (#641)
This commit is contained in:
parent
21a9802ed4
commit
74fac97257
21 changed files with 431 additions and 80 deletions
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
@ -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 },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue