BIT-1327: Add support for theme selection (#641)

This commit is contained in:
Caleb Derosier authored and Álison Fernandes committed 2024-06-20 17:08:07 +01:00
1 parent 21a9802ed4
commit 74fac97257
21 files changed
+431 -80

No files matched your search

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