Migrate DataAndStorageFragment to compose.

This commit is contained in:
Alex Hart
2025-08-22 16:18:54 -03:00
committed by Michelle Tang
parent 94ed0650dc
commit 45c64f825d
5 changed files with 419 additions and 111 deletions

View File

@@ -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<String>) {
viewModel.setMobileAutoDownloadValues(selection.toSet())
}
dividerPref()
override fun onWifiDataAutoDownloadSelectionChanged(selection: Array<String>) {
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<String>) {
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<String>) = Unit
fun onWifiDataAutoDownloadSelectionChanged(selection: Array<String>) = Unit
fun onRoamingDataAutoDownloadSelectionChanged(selection: Array<String>) = 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
)
}
}

View File

@@ -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<DataAndStorageSettingsState> = store.stateLiveData
val state: StateFlow<DataAndStorageSettingsState> = store
fun refresh() {
repository.getTotalStorageUse { totalStorageUse ->

View File

@@ -251,14 +251,14 @@
</string-array>
<!-- discrete MIME type (the part before the "/") -->
<string-array name="pref_media_download_entries">
<string-array name="pref_media_download_values">
<item>image</item>
<item>audio</item>
<item>video</item>
<item>documents</item>
</string-array>
<string-array name="pref_media_download_values">
<string-array name="pref_media_download_entries">
<item>@string/arrays__images</item>
<item>@string/arrays__audio</item>
<item>@string/arrays__video</item>

View File

@@ -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<String>,
values: Array<String>,
selection: Array<String>,
onSelectionChanged: (Array<String>) -> 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.

View File

@@ -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<String>,
values: Array<String>,
selection: Array<String>,
onSelectionChanged: (Array<String>) -> 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
}
)
}
}