diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt index 9a0b000c09..aa1dca296a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsFragment.kt @@ -1,39 +1,40 @@ package org.thoughtcrime.securesms.components.settings.app.appearance import android.os.Build -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.Navigation -import org.signal.core.util.concurrent.observe +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalPreview import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.appearance.navbar.ChooseNavigationBarStyleFragment -import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.keyvalue.SettingsValues -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate -class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__appearance) { +/** + * Allows the user to change language, theme, etc. from application settings. + */ +class AppearanceSettingsFragment : ComposeFragment() { - private lateinit var viewModel: AppearanceSettingsViewModel - - private val themeLabels by lazy { resources.getStringArray(R.array.pref_theme_entries) } - private val themeValues by lazy { resources.getStringArray(R.array.pref_theme_values) } - - private val messageFontSizeLabels by lazy { resources.getStringArray(R.array.pref_message_font_size_entries) } - private val messageFontSizeValues by lazy { resources.getIntArray(R.array.pref_message_font_size_values) } - - private val languageLabels by lazy { resources.getStringArray(R.array.language_entries) } - private val languageValues by lazy { resources.getStringArray(R.array.language_values) } - - override fun bindAdapter(adapter: MappingAdapter) { - viewModel = ViewModelProvider(this)[AppearanceSettingsViewModel::class.java] - - viewModel.state.observe(viewLifecycleOwner) { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } + private val viewModel: AppearanceSettingsViewModel by viewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { childFragmentManager.setFragmentResultListener(ChooseNavigationBarStyleFragment.REQUEST_KEY, viewLifecycleOwner) { key, bundle -> if (bundle.getBoolean(key, false)) { viewModel.refreshState() @@ -41,64 +42,148 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app } } - private fun getConfiguration(state: AppearanceSettingsState): DSLConfiguration { - return configure { - radioListPref( - title = DSLSettingsText.from(R.string.preferences__language), - listItems = languageLabels, - selected = languageValues.indexOf(state.language), - onSelected = { - viewModel.setLanguage(languageValues[it]) - } - ) + @Composable + override fun FragmentContent() { + val callbacks = remember { Callbacks() } + val state by viewModel.state.collectAsStateWithLifecycle() - radioListPref( - title = DSLSettingsText.from(R.string.preferences__theme), - listItems = themeLabels, - selected = themeValues.indexOf(state.theme.serialize()), - onSelected = { - viewModel.setTheme(activity, SettingsValues.Theme.deserialize(themeValues[it])) - } - ) + AppearanceSettingsScreen( + state = state, + callbacks = callbacks + ) + } - clickPref( - title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_wallpaperActivity) - } - ) + private inner class Callbacks : AppearanceSettingsCallbacks { + override fun onNavigationClick() { + requireActivity().onBackPressedDispatcher.onBackPressed() + } - if (Build.VERSION.SDK_INT >= 26) { - clickPref( - title = DSLSettingsText.from(R.string.preferences__app_icon), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity) - } - ) - } + override fun onLanguageSelected(selection: String) { + viewModel.setLanguage(selection) + } - radioListPref( - title = DSLSettingsText.from(R.string.preferences_chats__message_text_size), - listItems = messageFontSizeLabels, - selected = messageFontSizeValues.indexOf(state.messageFontSize), - onSelected = { - viewModel.setMessageFontSize(messageFontSizeValues[it].toInt()) - } - ) + override fun onThemeSelected(selection: String) { + viewModel.setTheme(activity, SettingsValues.Theme.deserialize(selection)) + } - clickPref( - title = DSLSettingsText.from(R.string.preferences_navigation_bar_size), - summary = DSLSettingsText.from( - if (state.isCompactNavigationBar) { - R.string.preferences_compact - } else { - R.string.preferences_normal - } - ), - onClick = { - ChooseNavigationBarStyleFragment().show(childFragmentManager, null) - } - ) + override fun onChatColorAndWallpaperClick() { + findNavController().safeNavigate(R.id.action_appearanceSettings_to_wallpaperActivity) + } + + override fun onAppIconClick() { + findNavController().safeNavigate(R.id.action_appearanceSettings_to_appIconActivity) + } + + override fun onMessageFontSizeSelected(selection: String) { + viewModel.setMessageFontSize(selection.toInt()) + } + + override fun onNavigationBarSizeClick() { + ChooseNavigationBarStyleFragment().show(childFragmentManager, null) } } } + +interface AppearanceSettingsCallbacks { + fun onNavigationClick() = Unit + fun onLanguageSelected(selection: String) = Unit + fun onThemeSelected(selection: String) = Unit + fun onChatColorAndWallpaperClick() = Unit + fun onAppIconClick() = Unit + fun onMessageFontSizeSelected(selection: String) = Unit + fun onNavigationBarSizeClick() = Unit + + object Empty : AppearanceSettingsCallbacks +} + +@Composable +private fun AppearanceSettingsScreen( + state: AppearanceSettingsState, + callbacks: AppearanceSettingsCallbacks +) { + Scaffolds.Settings( + title = stringResource(R.string.preferences__appearance), + onNavigationClick = callbacks::onNavigationClick, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24) + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues) + ) { + item { + Rows.RadioListRow( + text = stringResource(R.string.preferences__language), + labels = stringArrayResource(R.array.language_entries), + values = stringArrayResource(R.array.language_values), + selectedValue = state.language, + onSelected = callbacks::onLanguageSelected + ) + } + + item { + Rows.RadioListRow( + text = stringResource(R.string.preferences__theme), + labels = stringArrayResource(R.array.pref_theme_entries), + values = stringArrayResource(R.array.pref_theme_values), + selectedValue = state.theme.serialize(), + onSelected = callbacks::onThemeSelected + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.preferences__chat_color_and_wallpaper), + onClick = callbacks::onChatColorAndWallpaperClick + ) + } + + if (Build.VERSION.SDK_INT >= 26) { + item { + Rows.TextRow( + text = stringResource(R.string.preferences__app_icon), + onClick = callbacks::onAppIconClick + ) + } + } + + item { + Rows.RadioListRow( + text = stringResource(R.string.preferences_chats__message_text_size), + labels = stringArrayResource(R.array.pref_message_font_size_entries), + values = integerArrayResource(R.array.pref_message_font_size_values).map { it.toString() }.toTypedArray(), + selectedValue = state.messageFontSize.toString(), + onSelected = callbacks::onMessageFontSizeSelected + ) + } + + item { + val label = if (state.isCompactNavigationBar) { + R.string.preferences_compact + } else { + R.string.preferences_normal + } + + Rows.TextRow( + text = stringResource(R.string.preferences_navigation_bar_size), + label = stringResource(label), + onClick = callbacks::onNavigationBarSizeClick + ) + } + } + } +} + +@SignalPreview +@Composable +private fun AppearanceSettingsScreenPreview() { + Previews.Preview { + AppearanceSettingsScreen( + state = AppearanceSettingsState( + theme = SettingsValues.Theme.SYSTEM, + messageFontSize = 0, + language = "en-US", + isCompactNavigationBar = false + ), + callbacks = AppearanceSettingsCallbacks.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt index fb5c2203fc..481de9b2cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/appearance/AppearanceSettingsViewModel.kt @@ -2,21 +2,17 @@ package org.thoughtcrime.securesms.components.settings.app.appearance import android.app.Activity import androidx.lifecycle.ViewModel -import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob import org.thoughtcrime.securesms.keyvalue.SettingsValues.Theme import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.SplashScreenUtil -import org.thoughtcrime.securesms.util.rx.RxStore class AppearanceSettingsViewModel : ViewModel() { - private val store = RxStore(getState()) - val state: Flowable = store.stateFlowable - - override fun onCleared() { - super.onCleared() - store.dispose() - } + private val store = MutableStateFlow(getState()) + val state: StateFlow = store fun refreshState() { store.update { getState() } diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index b420232ce2..bdda0691e8 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -7,22 +7,28 @@ package org.signal.core.ui.compose import android.R import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -218,7 +224,9 @@ object Dialogs { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().fillMaxHeight() + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() ) { Spacer(modifier = Modifier.size(24.dp)) CircularProgressIndicator() @@ -342,6 +350,76 @@ object Dialogs { } } + @Composable + fun RadioListDialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + title: String, + labels: Array, + values: Array, + selectedIndex: Int, + onSelected: (Int) -> Unit + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = properties + ) { + Surface( + modifier = Modifier + .padding(vertical = 100.dp) + .background( + color = SignalTheme.colors.colorSurface2, + shape = AlertDialogDefaults.shape + ) + .clip(AlertDialogDefaults.shape) + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 16.dp).horizontalGutters() + ) + + LazyColumn( + modifier = Modifier.padding(top = 24.dp, bottom = 16.dp), + state = rememberLazyListState( + initialFirstVisibleItemIndex = selectedIndex + ) + ) { + items( + count = values.size, + key = { values[it] } + ) { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .clickable( + enabled = true, + onClick = { + onSelected(index) + onDismissRequest() + } + ) + .horizontalGutters() + ) { + RadioButton( + enabled = true, + selected = index == selectedIndex, + onClick = null, + modifier = Modifier.padding(end = 24.dp) + ) + + Text(text = labels[index]) + } + } + } + } + } + } + } + /** * Alert dialog that supports three options. * If you only need two options (confirm/dismiss), use [SimpleAlertDialog] instead. @@ -485,3 +563,19 @@ private fun IndeterminateProgressDialogCancellablePreview() { Dialogs.IndeterminateProgressDialog("Completing...", "Do not close app", "Cancel") {} } } + +@SignalPreview +@Composable +private fun RadioListDialogPreview() { + Previews.Preview { + Dialogs.RadioListDialog( + onDismissRequest = {}, + title = "TestDialog", + properties = DialogProperties(), + labels = arrayOf(), + values = arrayOf(), + selectedIndex = -1, + onSelected = {} + ) + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt index 01b9ec8cb7..187823d696 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -144,6 +144,45 @@ object Rows { } } + @Composable + fun RadioListRow( + text: String, + labels: Array, + values: Array, + selectedValue: String, + onSelected: (String) -> Unit + ) { + val selectedIndex = values.indexOf(selectedValue) + val selectedLabel = if (selectedIndex in labels.indices) { + labels[selectedIndex] + } else { + null + } + + var displayDialog by remember { mutableStateOf(false) } + + TextRow( + text = text, + label = selectedLabel, + onClick = { + displayDialog = true + } + ) + + if (displayDialog) { + Dialogs.RadioListDialog( + onDismissRequest = { displayDialog = false }, + labels = labels, + values = values, + selectedIndex = selectedIndex, + title = text, + onSelected = { + onSelected(values[it]) + } + ) + } + } + /** * Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch]. * @@ -565,3 +604,21 @@ private fun TextAndLabelPreview() { } } } + +@SignalPreview +@Composable +private fun RadioListRowPreview() { + var selectedValue by remember { mutableStateOf("b") } + + Previews.Preview { + Rows.RadioListRow( + text = "Radio List", + labels = arrayOf("A", "B", "C"), + values = arrayOf("a", "b", "c"), + selectedValue = selectedValue, + onSelected = { + selectedValue = it + } + ) + } +}