Skip to content

Commit

Permalink
feat: selectable app theme [WPB-4483] (#2379)
Browse files Browse the repository at this point in the history
Co-authored-by: Oussama Hassine <[email protected]>
  • Loading branch information
Garzas and ohassine authored Nov 1, 2023
1 parent ca803a7 commit a3f34b0
Show file tree
Hide file tree
Showing 13 changed files with 524 additions and 111 deletions.
12 changes: 12 additions & 0 deletions app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Preferences> by preferencesDataStore(name = PREFERENCES_NAME)
private fun userMigrationStatusKey(userId: String): Preferences.Key<Int> = intPreferencesKey("user_migration_status_$userId")
private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key<Boolean> =
Expand All @@ -68,6 +70,9 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex
private fun getBooleanPreference(key: Preferences.Key<Boolean>, defaultValue: Boolean): Flow<Boolean> =
context.dataStore.data.map { it[key] ?: defaultValue }

private fun getStringPreference(key: Preferences.Key<String>, defaultValue: String): Flow<String> =
context.dataStore.data.map { it[key] ?: defaultValue }

fun isMigrationCompletedFlow(): Flow<Boolean> = getBooleanPreference(MIGRATION_COMPLETED, false)

suspend fun isMigrationCompleted(): Boolean = isMigrationCompletedFlow().firstOrNull() ?: false
Expand Down Expand Up @@ -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<ThemeOption> = getStringPreference(APP_THEME_OPTION, ThemeOption.SYSTEM.toString())
.map { ThemeOption.valueOf(it) }
}
10 changes: 10 additions & 0 deletions app/src/main/kotlin/com/wire/android/ui/WireActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ThemeData>,
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(), {}, {})
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading

0 comments on commit a3f34b0

Please sign in to comment.