Convert expire timer settings fragment to compose.

This commit is contained in:
Alex Hart
2025-11-03 11:11:16 -04:00
committed by Michelle Tang
parent 4b5c9723c1
commit 683da1f167
5 changed files with 205 additions and 105 deletions

View File

@@ -107,12 +107,12 @@ fun InternalBackupStatsTab(stats: InternalBackupPlaygroundViewModel.StatsState,
CircularProgressIndicator()
} else if (stats.remoteState != null) {
Rows.TextRow(
"Total media items ⭐",
text = "Total media items ⭐",
label = "${stats.remoteState.mediaCount}"
)
Rows.TextRow(
"Total media size ⭐",
text = "Total media size ⭐",
label = "${stats.remoteState.mediaSize} (~${stats.remoteState.mediaSize.bytes.toUnitString()})"
)

View File

@@ -5,115 +5,114 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CircularProgressWrapper
import org.signal.core.ui.compose.DayNightPreviews
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.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
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.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.livedata.ProcessState
import org.thoughtcrime.securesms.util.livedata.distinctUntilChanged
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
import kotlin.time.Duration.Companion.seconds
/**
* Depending on the arguments, can be used to set the universal expire timer, set expire timer
* for a individual or group recipient, or select a value and return it via result.
*/
class ExpireTimerSettingsFragment : DSLSettingsFragment(
titleId = R.string.PrivacySettingsFragment__disappearing_messages,
layoutId = R.layout.expire_timer_settings_fragment
) {
class ExpireTimerSettingsFragment : ComposeFragment() {
private lateinit var save: CircularProgressMaterialButton
private lateinit var viewModel: ExpireTimerSettingsViewModel
private val viewModel: ExpireTimerSettingsViewModel by viewModels(
ownerProducer = {
NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer)
},
factoryProducer = {
ExpireTimerSettingsViewModel.Factory(requireContext(), arguments.toConfig())
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
save = view.findViewById(R.id.timer_select_fragment_save)
save.setOnClickListener { viewModel.save() }
adjustListPaddingForSaveButton(view)
}
private fun adjustListPaddingForSaveButton(view: View) {
val recycler: RecyclerView = view.findViewById(R.id.recycler)
recycler.setPadding(recycler.paddingLeft, recycler.paddingTop, recycler.paddingRight, ViewUtil.dpToPx(80))
recycler.clipToPadding = false
}
override fun bindAdapter(adapter: MappingAdapter) {
val provider = ViewModelProvider(
NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer),
ExpireTimerSettingsViewModel.Factory(requireContext(), arguments.toConfig())
)
viewModel = provider.get(ExpireTimerSettingsViewModel::class.java)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
viewModel.state.distinctUntilChanged(ExpireTimerSettingsState::saveState).observe(viewLifecycleOwner) { state ->
when (val saveState: ProcessState<Int> = state.saveState) {
is ProcessState.Working -> {
save.setSpinning()
}
is ProcessState.Success -> {
if (state.isGroupCreate) {
requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(FOR_RESULT_VALUE, saveState.result))
}
save.isClickable = false
requireActivity().onNavigateUp()
}
is ProcessState.Failure -> {
val groupChangeFailureReason: GroupChangeFailureReason = saveState.throwable?.let(GroupChangeFailureReason::fromException) ?: GroupChangeFailureReason.OTHER
Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeFailureReason), Toast.LENGTH_LONG).show()
viewModel.resetError()
}
else -> {
save.cancelSpinning()
}
else -> Unit
}
}
}
private fun getConfiguration(state: ExpireTimerSettingsState): DSLConfiguration {
return configure {
textPref(
summary = DSLSettingsText.from(
if (state.isForRecipient) {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_this_chat_will_disappear_after_they_have_been_seen
} else {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_new_chats_started_by_you_will_disappear_after_they_have_been_seen
}
)
@Composable
override fun FragmentContent() {
val state by viewModel.state.observeAsState(ExpireTimerSettingsState())
val callback = remember { DefaultExpireTimerSettingsScreenCallback(viewModel) }
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
ExpireTimerSettingsScreen(
state = state,
callback = callback
)
}
}
val labels: Array<String> = resources.getStringArray(R.array.ExpireTimerSettingsFragment__labels)
val values: Array<Int> = resources.getIntArray(R.array.ExpireTimerSettingsFragment__values).toTypedArray()
inner class DefaultExpireTimerSettingsScreenCallback(
private val viewModel: ExpireTimerSettingsViewModel
) : ExpireTimerSettingsScreenCallback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
var hasCustomValue = true
labels.zip(values).forEach { (label, seconds) ->
radioPref(
title = DSLSettingsText.from(label),
isChecked = state.currentTimer == seconds,
onClick = { viewModel.select(seconds) }
)
hasCustomValue = hasCustomValue && state.currentTimer != seconds
}
override fun onTimerSelected(seconds: Int) {
viewModel.select(seconds)
}
radioPref(
title = DSLSettingsText.from(R.string.ExpireTimerSettingsFragment__custom_time),
summary = if (hasCustomValue) DSLSettingsText.from(ExpirationUtil.getExpirationDisplayValue(requireContext(), state.currentTimer)) else null,
isChecked = hasCustomValue,
onClick = { NavHostFragment.findNavController(this@ExpireTimerSettingsFragment).safeNavigate(R.id.action_expireTimerSettingsFragment_to_customExpireTimerSelectDialog) }
)
override fun onCustomTimerClick() {
NavHostFragment.findNavController(this@ExpireTimerSettingsFragment).safeNavigate(R.id.action_expireTimerSettingsFragment_to_customExpireTimerSelectDialog)
}
override fun onSaveClick() {
viewModel.save()
}
}
@@ -122,6 +121,132 @@ class ExpireTimerSettingsFragment : DSLSettingsFragment(
}
}
@Composable
fun ExpireTimerSettingsScreen(
state: ExpireTimerSettingsState,
callback: ExpireTimerSettingsScreenCallback
) {
val context = LocalContext.current
val labels = context.resources.getStringArray(R.array.ExpireTimerSettingsFragment__labels)
val values = context.resources.getIntArray(R.array.ExpireTimerSettingsFragment__values)
Scaffolds.Settings(
title = stringResource(R.string.PrivacySettingsFragment__disappearing_messages),
onNavigationClick = callback::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn() {
item {
Rows.TextRow(
label = stringResource(
if (state.isForRecipient) {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_this_chat_will_disappear_after_they_have_been_seen
} else {
R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_new_chats_started_by_you_will_disappear_after_they_have_been_seen
}
)
)
}
items(labels.size) { index ->
val label = labels[index]
val seconds = values[index]
Rows.RadioRow(
selected = state.currentTimer == seconds,
text = label,
modifier = Modifier.clickable { callback.onTimerSelected(seconds) },
enabled = true
)
}
item {
val hasCustomValue = values.none { it == state.currentTimer }
val customSummary = if (hasCustomValue) {
ExpirationUtil.getExpirationDisplayValue(context, state.currentTimer)
} else {
null
}
Rows.RadioRow(
selected = hasCustomValue,
text = stringResource(R.string.ExpireTimerSettingsFragment__custom_time),
label = customSummary,
modifier = Modifier.clickable { callback.onCustomTimerClick() },
enabled = true
)
}
}
CircularProgressWrapper(
isLoading = state.saveState is ProcessState.Working,
modifier = Modifier
.align(Alignment.BottomEnd)
.horizontalGutters()
.padding(bottom = 16.dp)
) {
Buttons.LargeTonal(
onClick = callback::onSaveClick,
enabled = state.saveState is ProcessState.Idle
) {
Text(text = stringResource(R.string.ExpireTimerSettingsFragment__save))
}
}
}
}
}
@DayNightPreviews
@Composable
private fun ExpireTimerSettingsScreenPreview() {
var isLoading by remember {
mutableStateOf(false)
}
LaunchedEffect(isLoading) {
if (isLoading) {
delay(3.seconds)
isLoading = false
}
}
val state = remember(isLoading) {
ExpireTimerSettingsState(
initialTimer = 0,
userSetTimer = null,
isForRecipient = false,
isGroupCreate = false,
saveState = if (isLoading) ProcessState.Working() else ProcessState.Idle()
)
}
Previews.Preview {
ExpireTimerSettingsScreen(
state = state,
callback = object : ExpireTimerSettingsScreenCallback {
override fun onNavigationClick() = Unit
override fun onTimerSelected(seconds: Int) = Unit
override fun onCustomTimerClick() = Unit
override fun onSaveClick() {
isLoading = true
}
}
)
}
}
interface ExpireTimerSettingsScreenCallback {
fun onNavigationClick()
fun onTimerSelected(seconds: Int)
fun onCustomTimerClick()
fun onSaveClick()
}
private fun Bundle?.toConfig(): ExpireTimerSettingsViewModel.Config {
if (this == null) {
return ExpireTimerSettingsViewModel.Config()

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include
layout="@layout/dsl_settings_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
android:id="@+id/timer_select_fragment_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:circularProgressMaterialButton__label="@string/ExpireTimerSettingsFragment__save" />
</FrameLayout>

View File

@@ -44,8 +44,8 @@ import kotlin.time.Duration.Companion.seconds
@Composable
fun CircularProgressWrapper(
isLoading: Boolean,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
contentAlignment = Alignment.Center,

View File

@@ -363,9 +363,9 @@ object Rows {
*/
@Composable
fun TextRow(
text: String,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: String? = null,
label: String? = null,
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
@@ -374,7 +374,7 @@ object Rows {
enabled: Boolean = true
) {
TextRow(
text = remember(text) { AnnotatedString(text) },
text = remember(text) { text?.let { AnnotatedString(text) } },
label = remember(label) { label?.let { AnnotatedString(label) } },
icon = icon,
modifier = modifier,
@@ -391,9 +391,9 @@ object Rows {
*/
@Composable
fun TextRow(
text: AnnotatedString,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: AnnotatedString? = null,
label: AnnotatedString? = null,
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
@@ -434,10 +434,10 @@ object Rows {
*/
@Composable
fun TextRow(
text: String,
icon: ImageVector?,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
text: String? = null,
label: String? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
iconTint: Color = foregroundTint,