Merge pull request #1844 from vector-im/feature/bma/themeSwitch
Feature/bma/theme switch
This commit is contained in:
@@ -25,6 +25,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
@@ -38,6 +41,9 @@ import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
import io.element.android.libraries.theme.theme.isDark
|
||||
import io.element.android.libraries.theme.theme.mapToTheme
|
||||
import io.element.android.x.di.AppBindings
|
||||
import io.element.android.x.intent.SafeUriHandler
|
||||
import timber.log.Timber
|
||||
@@ -77,7 +83,13 @@ class MainActivity : NodeActivity() {
|
||||
|
||||
@Composable
|
||||
private fun MainContent(appBindings: AppBindings) {
|
||||
ElementTheme {
|
||||
val theme by remember {
|
||||
appBindings.preferencesStore().getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
ElementTheme(
|
||||
darkTheme = theme.isDark()
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
|
||||
LocalUriHandler provides SafeUriHandler(this),
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
@@ -29,4 +30,5 @@ interface AppBindings {
|
||||
fun tracingService(): TracingService
|
||||
fun bugReporter(): BugReporter
|
||||
fun lockScreenService(): LockScreenService
|
||||
fun preferencesStore(): PreferencesStore
|
||||
}
|
||||
|
||||
@@ -31,15 +31,22 @@ import android.webkit.PermissionRequest
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||
import io.element.android.features.call.CallForegroundService
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.di.CallBindings
|
||||
import io.element.android.features.call.utils.CallIntentDataParser
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
import io.element.android.libraries.theme.theme.isDark
|
||||
import io.element.android.libraries.theme.theme.mapToTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
|
||||
@@ -60,6 +67,7 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
|
||||
|
||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||
@Inject lateinit var preferencesStore: PreferencesStore
|
||||
|
||||
private lateinit var presenter: CallScreenPresenter
|
||||
|
||||
@@ -92,8 +100,14 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
|
||||
requestAudioFocus()
|
||||
|
||||
setContent {
|
||||
val theme by remember {
|
||||
preferencesStore.getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
val state = presenter.present()
|
||||
ElementTheme {
|
||||
ElementTheme(
|
||||
darkTheme = theme.isDark()
|
||||
) {
|
||||
CallScreenView(
|
||||
state = state,
|
||||
requestPermissions = { permissions, callback ->
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
|
||||
package io.element.android.features.preferences.impl.advanced
|
||||
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
|
||||
sealed interface AdvancedSettingsEvents {
|
||||
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||
data object ChangeTheme : AdvancedSettingsEvents
|
||||
data object CancelChangeTheme : AdvancedSettingsEvents
|
||||
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ package io.element.android.features.preferences.impl.advanced
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
import io.element.android.libraries.theme.theme.mapToTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -38,7 +43,11 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||
val isDeveloperModeEnabled by preferencesStore
|
||||
.isDeveloperModeEnabledFlow()
|
||||
.collectAsState(initial = false)
|
||||
|
||||
val theme by remember {
|
||||
preferencesStore.getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
var showChangeThemeDialog by remember { mutableStateOf(false) }
|
||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||
when (event) {
|
||||
is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
|
||||
@@ -47,12 +56,20 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
||||
preferencesStore.setDeveloperModeEnabled(event.enabled)
|
||||
}
|
||||
AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false
|
||||
AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true
|
||||
is AdvancedSettingsEvents.SetTheme -> localCoroutineScope.launch {
|
||||
preferencesStore.setTheme(event.theme.name)
|
||||
showChangeThemeDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AdvancedSettingsState(
|
||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
theme = theme,
|
||||
showChangeThemeDialog = showChangeThemeDialog,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
|
||||
package io.element.android.features.preferences.impl.advanced
|
||||
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
|
||||
data class AdvancedSettingsState(
|
||||
val isRichTextEditorEnabled: Boolean,
|
||||
val isDeveloperModeEnabled: Boolean,
|
||||
val theme: Theme,
|
||||
val showChangeThemeDialog: Boolean,
|
||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.preferences.impl.advanced
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
|
||||
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
|
||||
override val values: Sequence<AdvancedSettingsState>
|
||||
@@ -24,14 +25,18 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||
aAdvancedSettingsState(),
|
||||
aAdvancedSettingsState(isRichTextEditorEnabled = true),
|
||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||
aAdvancedSettingsState(showChangeThemeDialog = true),
|
||||
)
|
||||
}
|
||||
|
||||
fun aAdvancedSettingsState(
|
||||
isRichTextEditorEnabled: Boolean = false,
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
showChangeThemeDialog: Boolean = false,
|
||||
) = AdvancedSettingsState(
|
||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||
theme = Theme.System,
|
||||
showChangeThemeDialog = showChangeThemeDialog,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -21,11 +21,20 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ListOption
|
||||
import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
import io.element.android.libraries.theme.theme.themes
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun AdvancedSettingsView(
|
||||
@@ -38,6 +47,19 @@ fun AdvancedSettingsView(
|
||||
onBackPressed = onBackPressed,
|
||||
title = stringResource(id = CommonStrings.common_advanced_settings)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.common_appearance)
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Text(
|
||||
state.theme.toHumanReadable()
|
||||
),
|
||||
onClick = {
|
||||
state.eventSink(AdvancedSettingsEvents.ChangeTheme)
|
||||
}
|
||||
)
|
||||
PreferenceSwitch(
|
||||
title = stringResource(id = CommonStrings.common_rich_text_editor),
|
||||
subtitle = stringResource(id = R.string.screen_advanced_settings_rich_text_editor_description),
|
||||
@@ -51,6 +73,39 @@ fun AdvancedSettingsView(
|
||||
onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showChangeThemeDialog) {
|
||||
SingleSelectionDialog(
|
||||
options = getOptions(),
|
||||
initialSelection = themes.indexOf(state.theme),
|
||||
onOptionSelected = {
|
||||
state.eventSink(
|
||||
AdvancedSettingsEvents.SetTheme(
|
||||
themes[it]
|
||||
)
|
||||
)
|
||||
},
|
||||
onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangeTheme) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getOptions(): ImmutableList<ListOption> {
|
||||
return themes.map {
|
||||
ListOption(title = it.toHumanReadable())
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Theme.toHumanReadable(): String {
|
||||
return stringResource(
|
||||
id = when (this) {
|
||||
Theme.System -> CommonStrings.common_system
|
||||
Theme.Dark -> CommonStrings.common_dark
|
||||
Theme.Light -> CommonStrings.common_light
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
@@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
|
||||
import io.element.android.libraries.theme.theme.Theme
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -42,6 +43,8 @@ class AdvancedSettingsPresenterTest {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
assertThat(initialState.isDeveloperModeEnabled).isFalse()
|
||||
assertThat(initialState.isRichTextEditorEnabled).isFalse()
|
||||
assertThat(initialState.showChangeThemeDialog).isFalse()
|
||||
assertThat(initialState.theme).isEqualTo(Theme.System)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +79,28 @@ class AdvancedSettingsPresenterTest {
|
||||
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - change theme`() = runTest {
|
||||
val store = InMemoryPreferencesStore()
|
||||
val presenter = AdvancedSettingsPresenter(store)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitLastSequentialItem()
|
||||
initialState.eventSink.invoke(AdvancedSettingsEvents.ChangeTheme)
|
||||
val withDialog = awaitItem()
|
||||
assertThat(withDialog.showChangeThemeDialog).isTrue()
|
||||
// Cancel
|
||||
withDialog.eventSink(AdvancedSettingsEvents.CancelChangeTheme)
|
||||
val withoutDialog = awaitItem()
|
||||
assertThat(withoutDialog.showChangeThemeDialog).isFalse()
|
||||
withDialog.eventSink.invoke(AdvancedSettingsEvents.ChangeTheme)
|
||||
assertThat(awaitItem().showChangeThemeDialog).isTrue()
|
||||
withDialog.eventSink(AdvancedSettingsEvents.SetTheme(Theme.Light))
|
||||
val withNewTheme = awaitItem()
|
||||
assertThat(withNewTheme.showChangeThemeDialog).isFalse()
|
||||
assertThat(withNewTheme.theme).isEqualTo(Theme.Light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,8 @@ interface PreferencesStore {
|
||||
suspend fun setCustomElementCallBaseUrl(string: String?)
|
||||
fun getCustomElementCallBaseUrlFlow(): Flow<String?>
|
||||
|
||||
suspend fun setTheme(theme: String)
|
||||
fun getThemeFlow(): Flow<String?>
|
||||
|
||||
suspend fun reset()
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
||||
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
|
||||
private val developerModeKey = booleanPreferencesKey("developerMode")
|
||||
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
|
||||
private val themeKey = stringPreferencesKey("theme")
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPreferencesStore @Inject constructor(
|
||||
@@ -89,6 +90,18 @@ class DefaultPreferencesStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setTheme(theme: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[themeKey] = theme
|
||||
}
|
||||
}
|
||||
|
||||
override fun getThemeFlow(): Flow<String?> {
|
||||
return store.data.map { prefs ->
|
||||
prefs[themeKey]
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ class InMemoryPreferencesStore(
|
||||
isRichTextEditorEnabled: Boolean = false,
|
||||
isDeveloperModeEnabled: Boolean = false,
|
||||
customElementCallBaseUrl: String? = null,
|
||||
theme: String? = null,
|
||||
) : PreferencesStore {
|
||||
private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
|
||||
private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||
private var _customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||
private var _theme = MutableStateFlow(theme)
|
||||
|
||||
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
|
||||
_isRichTextEditorEnabled.value = enabled
|
||||
@@ -53,6 +55,14 @@ class InMemoryPreferencesStore(
|
||||
return _customElementCallBaseUrl
|
||||
}
|
||||
|
||||
override suspend fun setTheme(theme: String) {
|
||||
_theme.value = theme
|
||||
}
|
||||
|
||||
override fun getThemeFlow(): Flow<String?> {
|
||||
return _theme
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
// No op
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.theme.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class Theme {
|
||||
System,
|
||||
Dark,
|
||||
Light;
|
||||
}
|
||||
|
||||
val themes = listOf(Theme.System, Theme.Dark, Theme.Light)
|
||||
|
||||
@Composable
|
||||
fun Theme.isDark(): Boolean {
|
||||
return when (this) {
|
||||
Theme.System -> isSystemInDarkTheme()
|
||||
Theme.Dark -> true
|
||||
Theme.Light -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun Flow<String?>.mapToTheme(): Flow<Theme> = map {
|
||||
when (it) {
|
||||
null -> Theme.System
|
||||
else -> Theme.valueOf(it)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user