diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupStatsTab.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupStatsTab.kt index 5aa2e8bb48..3600f5f3bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupStatsTab.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupStatsTab.kt @@ -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()})" ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt index 7b2eb154aa..8f1f3344ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt @@ -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 = 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 = resources.getStringArray(R.array.ExpireTimerSettingsFragment__labels) - val values: Array = 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() diff --git a/app/src/main/res/layout/expire_timer_settings_fragment.xml b/app/src/main/res/layout/expire_timer_settings_fragment.xml deleted file mode 100644 index 25427e81e0..0000000000 --- a/app/src/main/res/layout/expire_timer_settings_fragment.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/CircularProgressWrapper.kt b/core-ui/src/main/java/org/signal/core/ui/compose/CircularProgressWrapper.kt index e0e1c6437b..b827f8e4e9 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/CircularProgressWrapper.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/CircularProgressWrapper.kt @@ -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, 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 a3c4117efb..7b5844f1b5 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 @@ -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,