From a3f34b0527240ff76f567962ac5725d2d15c0b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Wed, 1 Nov 2023 18:36:58 +0800 Subject: [PATCH] feat: selectable app theme [WPB-4483] (#2379) Co-authored-by: Oussama Hassine --- .../wire/android/datastore/GlobalDataStore.kt | 12 + .../com/wire/android/ui/WireActivity.kt | 10 + .../wire/android/ui/WireActivityViewModel.kt | 15 ++ .../android/ui/home/settings/SettingsItem.kt | 7 + .../ui/home/settings/SettingsScreen.kt | 1 + .../settings/appearance/AppearanceScreen.kt | 168 ++++++++++++ .../settings/appearance/AppearanceState.kt | 24 ++ .../appearance/AppearanceViewModel.kt | 52 ++++ .../com/wire/android/ui/theme/ThemeOption.kt | 26 ++ app/src/main/res/values/strings.xml | 7 + .../android/ui/WireActivityViewModelTest.kt | 252 ++++++++++-------- .../appearance/AppearanceViewModelTest.kt | 60 +++++ .../home/BackupAndRestoreViewModelTest.kt | 1 + 13 files changed, 524 insertions(+), 111 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModel.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/theme/ThemeOption.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index 01a0f703bce..abd7cca06aa 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -30,6 +30,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.wire.android.BuildConfig import com.wire.android.migration.failure.UserMigrationStatus +import com.wire.android.ui.theme.ThemeOption import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -52,6 +53,7 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private val IS_LOGGING_ENABLED = booleanPreferencesKey("is_logging_enabled") private val IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED = booleanPreferencesKey("is_encrypted_proteus_storage_enabled") private val APP_LOCK_PASSCODE = stringPreferencesKey("app_lock_passcode") + private val APP_THEME_OPTION = stringPreferencesKey("app_theme_option") private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) private fun userMigrationStatusKey(userId: String): Preferences.Key = intPreferencesKey("user_migration_status_$userId") private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key = @@ -68,6 +70,9 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private fun getBooleanPreference(key: Preferences.Key, defaultValue: Boolean): Flow = context.dataStore.data.map { it[key] ?: defaultValue } + private fun getStringPreference(key: Preferences.Key, defaultValue: String): Flow = + context.dataStore.data.map { it[key] ?: defaultValue } + fun isMigrationCompletedFlow(): Flow = getBooleanPreference(MIGRATION_COMPLETED, false) suspend fun isMigrationCompleted(): Boolean = isMigrationCompletedFlow().firstOrNull() ?: false @@ -172,4 +177,11 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex } } } + + suspend fun setThemeOption(option: ThemeOption) { + context.dataStore.edit { it[APP_THEME_OPTION] = option.toString() } + } + + fun selectedThemeOptionFlow(): Flow = getStringPreference(APP_THEME_OPTION, ThemeOption.SYSTEM.toString()) + .map { ThemeOption.valueOf(it) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 0cf7940a54f..a87649f7acc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -29,6 +29,7 @@ import androidx.activity.compose.ReportDrawnWhen import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.compose.foundation.layout.Column @@ -87,6 +88,7 @@ import com.wire.android.ui.home.E2EIRequiredDialog import com.wire.android.ui.home.E2EISnoozeDialog import com.wire.android.ui.home.appLock.LockCodeTimeManager import com.wire.android.ui.home.sync.FeatureFlagNotificationViewModel +import com.wire.android.ui.theme.ThemeOption import com.wire.android.ui.theme.WireTheme import com.wire.android.util.CurrentScreenManager import com.wire.android.util.LocalSyncStateObserver @@ -159,6 +161,14 @@ class WireActivity : AppCompatActivity() { val snackbarHostState = remember { SnackbarHostState() } var isLoaded by remember { mutableStateOf(false) } + LaunchedEffect(viewModel.globalAppState.themeOption) { + when (viewModel.globalAppState.themeOption) { + ThemeOption.SYSTEM -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + ThemeOption.LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + ThemeOption.DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + } + CompositionLocalProvider( LocalFeatureVisibilityFlags provides FeatureVisibilityFlags, LocalSyncStateObserver provides SyncStateObserver(viewModel.observeSyncFlowState), diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index 616b15cd67c..62700bb2fc7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.AuthServerConfigProvider import com.wire.android.di.KaliumCoreLogic import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider @@ -40,6 +41,7 @@ import com.wire.android.services.ServicesManager import com.wire.android.ui.authentication.devices.model.displayName import com.wire.android.ui.common.dialogs.CustomServerDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.deeplink.DeepLinkProcessor @@ -106,6 +108,7 @@ class WireActivityViewModel @Inject constructor( private val clearNewClientsForUser: ClearNewClientsForUserUseCase, private val currentScreenManager: CurrentScreenManager, private val observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory, + private val globalDataStore: GlobalDataStore, ) : ViewModel() { var globalAppState: GlobalAppState by mutableStateOf(GlobalAppState()) @@ -139,6 +142,17 @@ class WireActivityViewModel @Inject constructor( observeUpdateAppState() observeNewClientState() observeScreenshotCensoringConfigState() + observeAppThemeState() + } + + private fun observeAppThemeState() { + viewModelScope.launch(dispatchers.io()) { + globalDataStore.selectedThemeOptionFlow() + .distinctUntilChanged() + .collect { + globalAppState = globalAppState.copy(themeOption = it) + } + } } private fun observeSyncState() { @@ -482,6 +496,7 @@ data class GlobalAppState( val conversationJoinedDialog: JoinConversationViaCodeState? = null, val newClientDialog: NewClientsData? = null, val screenshotCensoringEnabled: Boolean = true, + val themeOption: ThemeOption = ThemeOption.SYSTEM ) enum class InitialAppState { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt index a060a39997b..1e6e4ff07a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsItem.kt @@ -41,6 +41,7 @@ import com.wire.android.ui.common.RowItemTemplate import com.wire.android.ui.common.clickable import com.wire.android.ui.common.dimensions import com.wire.android.ui.destinations.AppSettingsScreenDestination +import com.wire.android.ui.destinations.AppearanceScreenDestination import com.wire.android.ui.destinations.BackupAndRestoreScreenDestination import com.wire.android.ui.destinations.DebugScreenDestination import com.wire.android.ui.destinations.LicensesScreenDestination @@ -124,6 +125,12 @@ sealed class SettingsItem(open val id: String, open val title: UIText) { direction = MyAccountScreenDestination ) + data object Appearance : DirectionItem( + id = "appearance_settings", + title = UIText.StringResource(R.string.settings_appearance_label), + direction = AppearanceScreenDestination + ) + data object NetworkSettings : DirectionItem( id = "network_settings", title = UIText.StringResource(R.string.settings_network_settings_label), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt index 436c2263b37..9660ce3cb31 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/SettingsScreen.kt @@ -95,6 +95,7 @@ fun SettingsScreenContent( header = context.getString(R.string.settings_account_settings_label), items = buildList { add(SettingsItem.YourAccount) + add(SettingsItem.Appearance) add(SettingsItem.PrivacySettings) add(SettingsItem.ManageDevices) if (BackUpSettings) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceScreen.kt new file mode 100644 index 00000000000..26616a17c0d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceScreen.kt @@ -0,0 +1,168 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + * + */ + +package com.wire.android.ui.home.settings.appearance + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.selectableBackground +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.theme.ThemeData +import com.wire.android.ui.theme.ThemeOption +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.extension.folderWithElements +import com.wire.android.util.ui.PreviewMultipleThemes + +@RootNavGraph +@Destination +@Composable +fun AppearanceScreen( + navigator: Navigator, + viewModel: AppearanceViewModel = hiltViewModel() +) { + val lazyListState: LazyListState = rememberLazyListState() + AppearanceScreenContent( + lazyListState = lazyListState, + state = viewModel.state, + onThemeOptionChanged = viewModel::selectThemeOption, + onBackPressed = navigator::navigateBack + ) +} + +@Composable +fun AppearanceScreenContent( + lazyListState: LazyListState = rememberLazyListState(), + state: AppearanceState, + onThemeOptionChanged: (ThemeOption) -> Unit, + onBackPressed: () -> Unit, +) { + val context = LocalContext.current + WireScaffold(topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = onBackPressed, + elevation = 0.dp, + title = stringResource(id = R.string.settings_appearance_label) + ) + }) { internalPadding -> + LazyColumn( + state = lazyListState, + modifier = Modifier + .padding(internalPadding) + .fillMaxSize() + ) { + folderWithElements( + header = context.getString(R.string.settings_appearance_theme_label), + items = buildList { + add(ThemeData(option = ThemeOption.SYSTEM, selectedOption = state.selectedThemeOption)) + add(ThemeData(option = ThemeOption.LIGHT, selectedOption = state.selectedThemeOption)) + add(ThemeData(option = ThemeOption.DARK, selectedOption = state.selectedThemeOption)) + }, + onItemClicked = onThemeOptionChanged + ) + } + } +} + +private fun LazyListScope.folderWithElements( + header: String, + items: List, + onItemClicked: (ThemeOption) -> Unit +) { + folderWithElements( + header = header.uppercase(), + items = items.associateBy { it.option } + ) { themeItem -> + ThemeOptionItem( + themeOption = themeItem.option, + selectedOption = themeItem.selectedOption, + onItemClicked = onItemClicked + ) + } +} + +@Composable +fun ThemeOptionItem( + themeOption: ThemeOption, + selectedOption: ThemeOption, + onItemClicked: (ThemeOption) -> Unit +) { + val isSelected = themeOption == selectedOption + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensions().spacing1x) + .selectableBackground(isSelected, onClick = { onItemClicked(themeOption) }) + .background(color = MaterialTheme.wireColorScheme.surface), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = { onItemClicked(themeOption) }) + Text( + text = when (themeOption) { + ThemeOption.LIGHT -> buildAnnotatedString { append(stringResource(R.string.settings_appearance_theme_option_light)) } + ThemeOption.DARK -> buildAnnotatedString { append(stringResource(R.string.settings_appearance_theme_option_dark)) } + ThemeOption.SYSTEM -> buildAnnotatedString { + append(stringResource(R.string.settings_appearance_theme_option_system)) + append(" ") + withStyle( + style = SpanStyle(color = MaterialTheme.wireColorScheme.secondaryText) + ) { + append(stringResource(R.string.settings_appearance_theme_option_default_hint)) + } + } + }, + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + modifier = Modifier.padding(vertical = dimensions().spacing16x) + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewSettingsScreen() { + AppearanceScreenContent(rememberLazyListState(), AppearanceState(), {}, {}) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceState.kt new file mode 100644 index 00000000000..81df6fbba57 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceState.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.settings.appearance + +import com.wire.android.ui.theme.ThemeOption + +data class AppearanceState( + val selectedThemeOption: ThemeOption = ThemeOption.SYSTEM, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModel.kt new file mode 100644 index 00000000000..a1f3d312361 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModel.kt @@ -0,0 +1,52 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + * + */ + +package com.wire.android.ui.home.settings.appearance + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.theme.ThemeOption +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AppearanceViewModel @Inject constructor( + private val globalDataStore: GlobalDataStore, +) : ViewModel() { + var state by mutableStateOf(AppearanceState()) + private set + + init { + viewModelScope.launch { + globalDataStore.selectedThemeOptionFlow().collect { option -> state = state.copy(selectedThemeOption = option) } + } + } + + fun selectThemeOption(option: ThemeOption) { + viewModelScope.launch { + globalDataStore.setThemeOption(option) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/ThemeOption.kt b/app/src/main/kotlin/com/wire/android/ui/theme/ThemeOption.kt new file mode 100644 index 00000000000..61c30dc9b29 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/theme/ThemeOption.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.theme + +data class ThemeData(val option: ThemeOption, val selectedOption: ThemeOption) + +enum class ThemeOption { + SYSTEM, + LIGHT, + DARK +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6cdcbbba73..b169ea5b30b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -910,6 +910,13 @@ Keep Connection to Websocket Improve receiving notifications by keeping a constant connection to %1$s. It will replace notification services if Google Services are not available on your device. service is running + + Appearance + Theme + Sync with system settings + (Default) + Light mode + Dark mode Account Data PROFILE NAME diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index ba09f860703..f2a360f17c5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -26,6 +26,7 @@ import android.content.Intent import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri +import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.AuthServerConfigProvider import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.di.ObserveSyncStateUseCaseProvider @@ -35,6 +36,7 @@ import com.wire.android.framework.TestUser import com.wire.android.migration.MigrationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.joinConversation.JoinConversationViaCodeState +import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.deeplink.DeepLinkProcessor @@ -122,49 +124,52 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with ServerConfig, when currentSession is present, then initialAppState is LOGGED_IN and customBackEnd dialog is shown`() = runTest { - val result = DeepLinkResult.CustomServerConfig("url") - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with ServerConfig, when currentSession is present, then initialAppState is LOGGED_IN and customBackEnd dialog is shown`() = + runTest { + val result = DeepLinkResult.CustomServerConfig("url") + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertEquals(newServerConfig(1).links, viewModel.globalAppState.customBackendDialog!!.serverLinks) - } + assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) + verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } + assertEquals(newServerConfig(1).links, viewModel.globalAppState.customBackendDialog!!.serverLinks) + } @Test - fun `given Intent with ServerConfig, when currentSession is absent, then initialAppState is NOT_LOGGED_IN and customBackEnd dialog is shown`() = runTest { - val (arrangement, viewModel) = Arrangement() - .withNoCurrentSession() - .withDeepLinkResult(DeepLinkResult.CustomServerConfig("url")) - .arrange() + fun `given Intent with ServerConfig, when currentSession is absent, then initialAppState is NOT_LOGGED_IN and customBackEnd dialog is shown`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withNoCurrentSession() + .withDeepLinkResult(DeepLinkResult.CustomServerConfig("url")) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState) - verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertEquals(newServerConfig(1).links, viewModel.globalAppState.customBackendDialog!!.serverLinks) - } + assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState) + verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } + assertEquals(newServerConfig(1).links, viewModel.globalAppState.customBackendDialog!!.serverLinks) + } @Test - fun `given Intent with ServerConfig, when currentSession is absent and migration is required, then initialAppState is NOT_MIGRATED`() = runTest { - val (arrangement, viewModel) = Arrangement() - .withNoCurrentSession() - .withMigrationRequired() - .withDeepLinkResult(DeepLinkResult.CustomServerConfig("url")) - .withCurrentScreen(MutableStateFlow(CurrentScreen.Home)) - .arrange() + fun `given Intent with ServerConfig, when currentSession is absent and migration is required, then initialAppState is NOT_MIGRATED`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withNoCurrentSession() + .withMigrationRequired() + .withDeepLinkResult(DeepLinkResult.CustomServerConfig("url")) + .withCurrentScreen(MutableStateFlow(CurrentScreen.Home)) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.NOT_MIGRATED, viewModel.initialAppState) - verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertEquals(null, viewModel.globalAppState.customBackendDialog) - } + assertEquals(InitialAppState.NOT_MIGRATED, viewModel.initialAppState) + verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } + assertEquals(null, viewModel.globalAppState.customBackendDialog) + } @Test fun `given Intent with SSOLogin, when currentSession is present, then initialAppState is LOGGED_IN and result SSOLogin`() = runTest { @@ -195,46 +200,49 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with MigrationLogin, when currentSession is present, then initialAppState is LOGGED_IN and result MigrationLogin`() = runTest { - val result = DeepLinkResult.MigrationLogin("handle") - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with MigrationLogin, when currentSession is present, then initialAppState is LOGGED_IN and result MigrationLogin`() = + runTest { + val result = DeepLinkResult.MigrationLogin("handle") + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } + assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) + verify(exactly = 1) { arrangement.onDeepLinkResult(result) } + } @Test - fun `given Intent with MigrationLogin, when currentSession is absent, then initialAppState is NOT_LOGGED_IN and result MigrationLogin`() = runTest { - val result = DeepLinkResult.MigrationLogin("handle") - val (arrangement, viewModel) = Arrangement() - .withNoCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with MigrationLogin, when currentSession is absent, then initialAppState is NOT_LOGGED_IN and result MigrationLogin`() = + runTest { + val result = DeepLinkResult.MigrationLogin("handle") + val (arrangement, viewModel) = Arrangement() + .withNoCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } + assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState) + verify(exactly = 1) { arrangement.onDeepLinkResult(result) } + } @Test - fun `given Intent with IncomingCall, when currentSession is present, then initialAppState is LOGGED_IN and result IncomingCall`() = runTest { - val result = DeepLinkResult.IncomingCall(ConversationId("val", "dom")) - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with IncomingCall, when currentSession is present, then initialAppState is LOGGED_IN and result IncomingCall`() = + runTest { + val result = DeepLinkResult.IncomingCall(ConversationId("val", "dom")) + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } + assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) + verify(exactly = 1) { arrangement.onDeepLinkResult(result) } + } @Test fun `given Intent with IncomingCall, when currentSession is absent, then initialAppState is NOT_LOGGED_IN`() = runTest { @@ -251,18 +259,19 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with OpenConversation, when currentSession is present, then initialAppState is LOGGED_IN and result OpenConversation`() = runTest { - val result = DeepLinkResult.OpenConversation(ConversationId("val", "dom")) - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with OpenConversation, when currentSession is present, then initialAppState is LOGGED_IN and result OpenConversation`() = + runTest { + val result = DeepLinkResult.OpenConversation(ConversationId("val", "dom")) + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } + assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) + verify(exactly = 1) { arrangement.onDeepLinkResult(result) } + } @Test fun `given Intent with OpenConversation, when currentSession is absent, then initialAppState is NOT_LOGGED_IN`() = runTest { @@ -279,19 +288,20 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with OpenOtherUser, when currentSession is present, then then initialAppState is LOGGED_IN and result OpenOtherUserProfile`() = runTest { - val userId = QualifiedID("val", "dom") - val result = DeepLinkResult.OpenOtherUserProfile(userId) - val (arrangement, viewModel) = Arrangement() - .withSomeCurrentSession() - .withDeepLinkResult(result) - .arrange() + fun `given Intent with OpenOtherUser, when currentSession is present, then then initialAppState is LOGGED_IN and result OpenOtherUserProfile`() = + runTest { + val userId = QualifiedID("val", "dom") + val result = DeepLinkResult.OpenOtherUserProfile(userId) + val (arrangement, viewModel) = Arrangement() + .withSomeCurrentSession() + .withDeepLinkResult(result) + .arrange() - viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) + viewModel.handleDeepLink(mockedIntent(), {}, {}, arrangement.onDeepLinkResult) - assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) - verify(exactly = 1) { arrangement.onDeepLinkResult(result) } - } + assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState) + verify(exactly = 1) { arrangement.onDeepLinkResult(result) } + } @Test fun `given Intent with OpenOtherUser, when currentSession is absent, then initialAppState is NOT_LOGGED_IN`() = runTest { @@ -406,38 +416,40 @@ class WireActivityViewModelTest { } @Test - fun `given valid accounts, at least one with persistent socket enabled, and socket service not running, then start service`() = runTest { - val statuses = listOf( - PersistentWebSocketStatus(TestUser.SELF_USER.id, false), - PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) - ) - val (arrangement, manager) = Arrangement() - .withPersistentWebSocketConnectionStatuses(statuses) - .withIsPersistentWebSocketServiceRunning(false) - .arrange() + fun `given valid accounts, at least one with persistent socket enabled, and socket service not running, then start service`() = + runTest { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) + ) + val (arrangement, manager) = Arrangement() + .withPersistentWebSocketConnectionStatuses(statuses) + .withIsPersistentWebSocketServiceRunning(false) + .arrange() - manager.observePersistentConnectionStatus() + manager.observePersistentConnectionStatus() - coVerify(exactly = 1) { arrangement.servicesManager.startPersistentWebSocketService() } - coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } - } + coVerify(exactly = 1) { arrangement.servicesManager.startPersistentWebSocketService() } + coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } + } @Test - fun `given valid accounts, at least one with persistent socket enabled, and socket service running, then do not start service again`() = runTest { - val statuses = listOf( - PersistentWebSocketStatus(TestUser.SELF_USER.id, false), - PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) - ) - val (arrangement, manager) = Arrangement() - .withPersistentWebSocketConnectionStatuses(statuses) - .withIsPersistentWebSocketServiceRunning(true) - .arrange() + fun `given valid accounts, at least one with persistent socket enabled, and socket service running, then do not start service again`() = + runTest { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) + ) + val (arrangement, manager) = Arrangement() + .withPersistentWebSocketConnectionStatuses(statuses) + .withIsPersistentWebSocketServiceRunning(true) + .arrange() - manager.observePersistentConnectionStatus() + manager.observePersistentConnectionStatus() - coVerify(exactly = 0) { arrangement.servicesManager.startPersistentWebSocketService() } - coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } - } + coVerify(exactly = 0) { arrangement.servicesManager.startPersistentWebSocketService() } + coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } + } @Test fun `given newClient is registered for the current user, then should show the NewClient dialog`() = runTest { @@ -554,6 +566,15 @@ class WireActivityViewModelTest { assertEquals(false, viewModel.globalAppState.screenshotCensoringEnabled) } + @Test + fun `given app theme change, when observing it, then update state with theme option`() = runTest { + val (_, viewModel) = Arrangement() + .withThemeOption(ThemeOption.DARK) + .arrange() + advanceUntilIdle() + assertEquals(ThemeOption.DARK, viewModel.globalAppState.themeOption) + } + private class Arrangement { init { // Tests setup @@ -574,6 +595,7 @@ class WireActivityViewModelTest { observeScreenshotCensoringConfigUseCase coEvery { observeScreenshotCensoringConfigUseCase() } returns flowOf(ObserveScreenshotCensoringConfigResult.Disabled) coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(CurrentScreen.SomeOther) + coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(ThemeOption.LIGHT) } @MockK @@ -626,6 +648,9 @@ class WireActivityViewModelTest { @MockK private lateinit var observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory + @MockK + lateinit var globalDataStore: GlobalDataStore + @MockK(relaxed = true) lateinit var onDeepLinkResult: (DeepLinkResult) -> Unit @@ -649,7 +674,8 @@ class WireActivityViewModelTest { observeNewClients = observeNewClients, clearNewClientsForUser = clearNewClientsForUser, currentScreenManager = currentScreenManager, - observeScreenshotCensoringConfigUseCaseProviderFactory = observeScreenshotCensoringConfigUseCaseProviderFactory + observeScreenshotCensoringConfigUseCaseProviderFactory = observeScreenshotCensoringConfigUseCaseProviderFactory, + globalDataStore = globalDataStore ) } @@ -717,6 +743,10 @@ class WireActivityViewModelTest { coEvery { observeScreenshotCensoringConfigUseCase() } returns flowOf(result) } + suspend fun withThemeOption(themeOption: ThemeOption) = apply { + coEvery { globalDataStore.selectedThemeOptionFlow() } returns flowOf(themeOption) + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt new file mode 100644 index 00000000000..4d7fc083cbc --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/appearance/AppearanceViewModelTest.kt @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.settings.appearance + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.theme.ThemeOption +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class AppearanceViewModelTest { + + @Test + fun `given theme option, when changing it, then should update global data store`() = runTest { + val (arrangement, viewModel) = Arrangement() + .arrange() + + viewModel.selectThemeOption(ThemeOption.DARK) + + coVerify(exactly = 1) { arrangement.globalDataStore.setThemeOption(ThemeOption.DARK) } + } + + private class Arrangement { + @MockK + lateinit var globalDataStore: GlobalDataStore + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + coEvery { globalDataStore.setThemeOption(any()) } returns Unit + every { globalDataStore.selectedThemeOptionFlow() } returns flowOf(ThemeOption.DARK) + } + + private val viewModel = AppearanceViewModel(globalDataStore) + + fun arrange() = this to viewModel + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt index 95291a62a38..06a09f62d24 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt @@ -427,6 +427,7 @@ class BackupAndRestoreViewModelTest { val (arrangement, backupAndRestoreViewModel) = Arrangement() .withFailedDBImport(Failure(BackupIOFailure("IO error"))) .withRequestedPasswordDialog() + .withValidPassword() .arrange() // When