diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt index f84003be47..d0a8836837 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsFragment.kt @@ -1,134 +1,269 @@ package org.thoughtcrime.securesms.components.settings.app.data -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.Navigation +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.platform.LocalContext +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 androidx.preference.PreferenceManager +import org.signal.core.ui.compose.Dividers +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.signal.core.ui.compose.Texts import org.signal.core.util.bytes 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.configure +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier import org.thoughtcrime.securesms.mms.SentMediaQuality -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.webrtc.CallDataMode import kotlin.math.abs -class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences__data_and_storage) { +class DataAndStorageSettingsFragment : ComposeFragment() { - private val autoDownloadValues by lazy { resources.getStringArray(R.array.pref_media_download_entries) } - private val autoDownloadLabels by lazy { resources.getStringArray(R.array.pref_media_download_values) } - - private val sentMediaQualityLabels by lazy { SentMediaQuality.getLabels(requireContext()) } - - private val callDataModeLabels by lazy { resources.getStringArray(R.array.pref_data_and_storage_call_data_mode_values) } - - private lateinit var viewModel: DataAndStorageSettingsViewModel + private val viewModel: DataAndStorageSettingsViewModel by viewModels( + factoryProducer = { + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val repository = DataAndStorageSettingsRepository() + DataAndStorageSettingsViewModel.Factory(preferences, repository) + } + ) override fun onResume() { super.onResume() viewModel.refresh() } - override fun bindAdapter(adapter: MappingAdapter) { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val repository = DataAndStorageSettingsRepository() - val factory = DataAndStorageSettingsViewModel.Factory(preferences, repository) - viewModel = ViewModelProvider(this, factory)[DataAndStorageSettingsViewModel::class.java] + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + val callbacks = remember { Callbacks() } - viewModel.state.observe(viewLifecycleOwner) { - adapter.submitList(getConfiguration(it).toMappingModelList()) - } + DataAndStorageSettingsScreen( + state = state, + callbacks = callbacks + ) } - fun getConfiguration(state: DataAndStorageSettingsState): DSLConfiguration { - return configure { - clickPref( - title = DSLSettingsText.from(R.string.preferences_data_and_storage__manage_storage), - summary = DSLSettingsText.from(state.totalStorageUse.bytes.toUnitString()), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment) - } - ) + private inner class Callbacks : DataAndStorageSettingsCallbacks { + override fun onNavigationClick() { + requireActivity().onBackPressedDispatcher.onBackPressed() + } - dividerPref() + override fun onManageStorageClick() { + findNavController().safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment) + } - sectionHeaderPref(R.string.preferences_chats__media_auto_download) + override fun onSentMediaQualitySelected(code: String) { + viewModel.setSentMediaQuality(SentMediaQuality.fromCode(code.toInt())) + } - multiSelectPref( - title = DSLSettingsText.from(R.string.preferences_chats__when_using_mobile_data), - listItems = autoDownloadLabels, - selected = autoDownloadValues.map { state.mobileAutoDownloadValues.contains(it) }.toBooleanArray(), - onSelected = { - val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet() - viewModel.setMobileAutoDownloadValues(resultSet) - } - ) + override fun onCallDataModeSelected(code: String) { + viewModel.setCallDataMode(CallDataMode.fromCode(abs(code.toInt() - 2))) + } - multiSelectPref( - title = DSLSettingsText.from(R.string.preferences_chats__when_using_wifi), - listItems = autoDownloadLabels, - selected = autoDownloadValues.map { state.wifiAutoDownloadValues.contains(it) }.toBooleanArray(), - onSelected = { - val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet() - viewModel.setWifiAutoDownloadValues(resultSet) - } - ) + override fun onUseProxyClick() { + findNavController().safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment) + } - multiSelectPref( - title = DSLSettingsText.from(R.string.preferences_chats__when_roaming), - listItems = autoDownloadLabels, - selected = autoDownloadValues.map { state.roamingAutoDownloadValues.contains(it) }.toBooleanArray(), - onSelected = { - val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet() - viewModel.setRoamingAutoDownloadValues(resultSet) - } - ) + override fun onMobileDataAutoDownloadSelectionChanged(selection: Array) { + viewModel.setMobileAutoDownloadValues(selection.toSet()) + } - dividerPref() + override fun onWifiDataAutoDownloadSelectionChanged(selection: Array) { + viewModel.setWifiAutoDownloadValues(selection.toSet()) + } - sectionHeaderPref(R.string.DataAndStorageSettingsFragment__media_quality) - - radioListPref( - title = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sent_media_quality), - listItems = sentMediaQualityLabels, - selected = SentMediaQuality.entries.indexOf(state.sentMediaQuality), - onSelected = { viewModel.setSentMediaQuality(SentMediaQuality.entries[it]) } - ) - - textPref( - summary = DSLSettingsText.from(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data) - ) - - dividerPref() - - sectionHeaderPref(R.string.DataAndStorageSettingsFragment__calls) - - radioListPref( - title = DSLSettingsText.from(R.string.preferences_data_and_storage__use_less_data_for_calls), - listItems = callDataModeLabels, - selected = abs(state.callDataMode.code - 2), - onSelected = { - viewModel.setCallDataMode(CallDataMode.fromCode(abs(it - 2))) - } - ) - - textPref( - summary = DSLSettingsText.from(R.string.preference_data_and_storage__using_less_data_may_improve_calls_on_bad_networks) - ) - - dividerPref() - - sectionHeaderPref(R.string.preferences_proxy) - - clickPref( - title = DSLSettingsText.from(R.string.preferences_use_proxy), - summary = DSLSettingsText.from(if (state.isProxyEnabled) R.string.preferences_on else R.string.preferences_off), - onClick = { - Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment) - } - ) + override fun onRoamingDataAutoDownloadSelectionChanged(selection: Array) { + viewModel.setRoamingAutoDownloadValues(selection.toSet()) } } } + +private interface DataAndStorageSettingsCallbacks { + fun onNavigationClick() = Unit + fun onManageStorageClick() = Unit + fun onSentMediaQualitySelected(code: String) = Unit + fun onCallDataModeSelected(code: String) = Unit + fun onUseProxyClick() = Unit + fun onMobileDataAutoDownloadSelectionChanged(selection: Array) = Unit + fun onWifiDataAutoDownloadSelectionChanged(selection: Array) = Unit + fun onRoamingDataAutoDownloadSelectionChanged(selection: Array) = Unit + + object Empty : DataAndStorageSettingsCallbacks +} + +@Composable +private fun DataAndStorageSettingsScreen( + state: DataAndStorageSettingsState, + callbacks: DataAndStorageSettingsCallbacks +) { + Scaffolds.Settings( + title = stringResource(R.string.preferences__data_and_storage), + onNavigationClick = callbacks::onNavigationClick, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24) + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .then(rememberStatusBarColorNestedScrollModifier()) + ) { + item { + Rows.TextRow( + text = stringResource(R.string.preferences_data_and_storage__manage_storage), + label = state.totalStorageUse.bytes.toUnitString(), + onClick = callbacks::onManageStorageClick + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(stringResource(R.string.preferences_chats__media_auto_download)) + } + + item { + Rows.MultiSelectRow( + text = stringResource(R.string.preferences_chats__when_using_mobile_data), + labels = stringArrayResource(R.array.pref_media_download_entries), + values = stringArrayResource(R.array.pref_media_download_values), + selection = state.mobileAutoDownloadValues.toTypedArray(), + onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged + ) + } + + item { + Rows.MultiSelectRow( + text = stringResource(R.string.preferences_chats__when_using_wifi), + labels = stringArrayResource(R.array.pref_media_download_entries), + values = stringArrayResource(R.array.pref_media_download_values), + selection = state.wifiAutoDownloadValues.toTypedArray(), + onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged + ) + } + + item { + Rows.MultiSelectRow( + text = stringResource(R.string.preferences_chats__when_roaming), + labels = stringArrayResource(R.array.pref_media_download_entries), + values = stringArrayResource(R.array.pref_media_download_values), + selection = state.roamingAutoDownloadValues.toTypedArray(), + onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(stringResource(R.string.DataAndStorageSettingsFragment__media_quality)) + } + + item { + val context = LocalContext.current + val labels = remember { SentMediaQuality.getLabels(context) } + + Rows.RadioListRow( + text = stringResource(R.string.DataAndStorageSettingsFragment__sent_media_quality), + labels = labels, + values = SentMediaQuality.entries.map { it.code.toString() }.toTypedArray(), + selectedValue = state.sentMediaQuality.code.toString(), + onSelected = callbacks::onSentMediaQualitySelected + ) + } + + item { + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.DataAndStorageSettingsFragment__sending_high_quality_media_will_use_more_data), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(stringResource(R.string.DataAndStorageSettingsFragment__calls)) + } + + item { + Rows.RadioListRow( + text = stringResource(R.string.preferences_data_and_storage__use_less_data_for_calls), + labels = stringArrayResource(R.array.pref_data_and_storage_call_data_mode_values), + values = CallDataMode.entries.map { it.code.toString() }.toTypedArray(), + selectedValue = abs(state.callDataMode.code - 2).toString(), + onSelected = callbacks::onCallDataModeSelected + ) + } + + item { + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.preference_data_and_storage__using_less_data_may_improve_calls_on_bad_networks), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(stringResource(R.string.preferences_proxy)) + } + + item { + Rows.TextRow( + text = stringResource(R.string.preferences_use_proxy), + label = stringResource(if (state.isProxyEnabled) R.string.preferences_on else R.string.preferences_off), + onClick = callbacks::onUseProxyClick + ) + } + } + } +} + +@SignalPreview +@Composable +private fun DataAndStorageSettingsScreenPreview() { + Previews.Preview { + DataAndStorageSettingsScreen( + state = DataAndStorageSettingsState( + totalStorageUse = 100_000, + mobileAutoDownloadValues = setOf(), + wifiAutoDownloadValues = setOf(), + roamingAutoDownloadValues = setOf(), + callDataMode = CallDataMode.HIGH_ALWAYS, + isProxyEnabled = false, + sentMediaQuality = SentMediaQuality.STANDARD + ), + callbacks = DataAndStorageSettingsCallbacks.Empty + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt index df57a12a39..4cf4226b26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/data/DataAndStorageSettingsViewModel.kt @@ -1,14 +1,15 @@ package org.thoughtcrime.securesms.components.settings.app.data import android.content.SharedPreferences -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.util.TextSecurePreferences -import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.webrtc.CallDataMode class DataAndStorageSettingsViewModel( @@ -16,9 +17,9 @@ class DataAndStorageSettingsViewModel( private val repository: DataAndStorageSettingsRepository ) : ViewModel() { - private val store = Store(getState()) + private val store = MutableStateFlow(getState()) - val state: LiveData = store.stateLiveData + val state: StateFlow = store fun refresh() { repository.getTotalStorageUse { totalStorageUse -> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 50b7d22ccf..9aedbbe05a 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -251,14 +251,14 @@ - + image audio video documents - + @string/arrays__images @string/arrays__audio @string/arrays__video 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 bdda0691e8..4c3ec2bd23 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 @@ -25,6 +25,7 @@ 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.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -33,6 +34,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +46,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -377,7 +383,9 @@ object Dialogs { Text( text = title, style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(top = 16.dp).horizontalGutters() + modifier = Modifier + .padding(top = 16.dp) + .horizontalGutters() ) LazyColumn( @@ -420,6 +428,106 @@ object Dialogs { } } + @Composable + fun MultiSelectListDialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + title: String, + labels: Array, + values: Array, + selection: Array, + onSelectionChanged: (Array) -> Unit + ) { + var selectedIndicies by remember { + mutableStateOf( + values.mapIndexedNotNull { index, value -> + if (value in selection) { + index + } else { + null + } + } + ) + } + + 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) + ) { + items( + count = values.size, + key = { values[it] } + ) { index -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .clickable( + enabled = true, + onClick = { + selectedIndicies = if (index in selectedIndicies) { + selectedIndicies - index + } else { + selectedIndicies + index + } + } + ) + .horizontalGutters() + ) { + Checkbox( + enabled = true, + checked = index in selectedIndicies, + onCheckedChange = null, + modifier = Modifier.padding(end = 24.dp) + ) + + Text(text = labels[index]) + } + } + } + + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.cancel)) + } + + TextButton(onClick = { + onSelectionChanged(selectedIndicies.sorted().map { values[it] }.toTypedArray()) + onDismissRequest() + }) { + Text(text = stringResource(R.string.ok)) + } + } + } + } + } + } + /** * Alert dialog that supports three options. * If you only need two options (confirm/dismiss), use [SimpleAlertDialog] instead. 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 187823d696..cf7fc5b1a1 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 @@ -183,6 +183,52 @@ object Rows { } } + /* + multiSelectPref( + text = stringResource(R.string.preferences_chats__when_using_mobile_data), + listItems = autoDownloadLabels, + selected = autoDownloadValues.map { state.mobileAutoDownloadValues.contains(it) }.toBooleanArray(), + onSelected = { + val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet() + viewModel.setMobileAutoDownloadValues(resultSet) + } + ) + */ + + @Composable + fun MultiSelectRow( + text: String, + labels: Array, + values: Array, + selection: Array, + onSelectionChanged: (Array) -> Unit + ) { + var displayDialog by remember { mutableStateOf(false) } + + TextRow( + text = text, + label = selection.joinToString(", ") { + val index = values.indexOf(it) + if (index == -1) error("not found: $it in ${values.joinToString(", ")}") + labels[index] + }, + onClick = { + displayDialog = true + } + ) + + if (displayDialog) { + Dialogs.MultiSelectListDialog( + onDismissRequest = { displayDialog = false }, + labels = labels, + values = values, + selection = selection, + title = text, + onSelectionChanged = onSelectionChanged + ) + } + } + /** * Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch]. * @@ -622,3 +668,21 @@ private fun RadioListRowPreview() { ) } } + +@SignalPreview +@Composable +private fun MultiSelectRowPreview() { + var selectedValues by remember { mutableStateOf(arrayOf("b")) } + + Previews.Preview { + Rows.MultiSelectRow( + text = "MultiSelect List", + labels = arrayOf("A", "B", "C"), + values = arrayOf("a", "b", "c"), + selection = selectedValues, + onSelectionChanged = { + selectedValues = it + } + ) + } +}