Convert InternalDonorErrorConfigurationFragment to Compose.

This commit is contained in:
Alex Hart
2025-11-07 10:41:55 -04:00
committed by Michelle Tang
parent d241aebade
commit 632aec423f
4 changed files with 238 additions and 93 deletions

View File

@@ -1,65 +1,238 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.Buttons
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.theme.SignalTheme
import org.signal.donations.StripeDeclineCode
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.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.DynamicTheme
class InternalDonorErrorConfigurationFragment : DSLSettingsFragment() {
/**
* Internal tool for configuring donor error states for testing.
*/
class InternalDonorErrorConfigurationFragment : ComposeFragment() {
private val viewModel: InternalDonorErrorConfigurationViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: MappingAdapter) {
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
InternalDonorErrorConfigurationScreen(
state = state,
callback = remember { DefaultInternalDonorErrorConfigurationCallback() }
)
}
}
private fun getConfiguration(state: InternalDonorErrorConfigurationState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from("Expired Badge"),
selected = state.badges.indexOf(state.selectedBadge),
listItems = state.badges.map { it.name }.toTypedArray(),
onSelected = { viewModel.setSelectedBadge(it) }
)
/**
* Default callback that bridges UI interactions to ViewModel updates.
*/
inner class DefaultInternalDonorErrorConfigurationCallback : InternalDonorErrorConfigurationScreenCallback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
radioListPref(
title = DSLSettingsText.from("Cancellation Reason"),
selected = UnexpectedSubscriptionCancellation.entries.indexOf(state.selectedUnexpectedSubscriptionCancellation),
listItems = UnexpectedSubscriptionCancellation.entries.map { it.status }.toTypedArray(),
onSelected = { viewModel.setSelectedUnexpectedSubscriptionCancellation(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
override fun onSelectedBadgeChanged(badgeId: String) {
val index = viewModel.state.value.badges.indexOfFirst { it.id == badgeId }
if (index >= 0) {
viewModel.setSelectedBadge(index)
}
}
radioListPref(
title = DSLSettingsText.from("Charge Failure"),
selected = StripeDeclineCode.Code.entries.indexOf(state.selectedStripeDeclineCode),
listItems = StripeDeclineCode.Code.entries.map { it.code }.toTypedArray(),
onSelected = { viewModel.setStripeDeclineCode(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
override fun onSelectedCancellationChanged(status: String) {
val index = UnexpectedSubscriptionCancellation.entries.indexOfFirst { it.status == status }
if (index >= 0) {
viewModel.setSelectedUnexpectedSubscriptionCancellation(index)
}
}
primaryButton(
text = DSLSettingsText.from("Save and Finish"),
onClick = {
lifecycleDisposable += viewModel.save().subscribe { requireActivity().finish() }
}
)
override fun onSelectedDeclineCodeChanged(code: String) {
val index = StripeDeclineCode.Code.entries.indexOfFirst { it.code == code }
if (index >= 0) {
viewModel.setStripeDeclineCode(index)
}
}
secondaryButtonNoOutline(
text = DSLSettingsText.from("Clear"),
onClick = {
lifecycleDisposable += viewModel.clearErrorState().subscribe()
}
)
override fun onSaveClick() {
viewModel.save().subscribe { requireActivity().finish() }
}
override fun onClearClick() {
viewModel.clearErrorState().subscribe()
}
}
}
/**
* Screen for configuring donor error states.
*/
@Composable
fun InternalDonorErrorConfigurationScreen(
state: InternalDonorErrorConfigurationState,
callback: InternalDonorErrorConfigurationScreenCallback
) {
Scaffolds.Settings(
title = "Donor Error Configuration",
onNavigationClick = callback::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
val badgeLabels = state.badges.map { it.name }.toTypedArray()
val badgeValues = state.badges.map { it.id }.toTypedArray()
val selectedBadgeValue = state.selectedBadge?.id ?: ""
Rows.RadioListRow(
text = "Expired Badge",
labels = badgeLabels,
values = badgeValues,
selectedValue = selectedBadgeValue,
onSelected = callback::onSelectedBadgeChanged
)
val cancellationLabels = UnexpectedSubscriptionCancellation.entries.map { it.status }.toTypedArray()
val cancellationValues = UnexpectedSubscriptionCancellation.entries.map { it.status }.toTypedArray()
val selectedCancellationValue = state.selectedUnexpectedSubscriptionCancellation?.status ?: ""
val cancellationEnabled = state.selectedBadge?.isSubscription() == true
Rows.RadioListRow(
text = "Cancellation Reason",
labels = cancellationLabels,
values = cancellationValues,
selectedValue = selectedCancellationValue,
onSelected = callback::onSelectedCancellationChanged,
enabled = cancellationEnabled
)
val declineCodeLabels = StripeDeclineCode.Code.entries.map { it.code }.toTypedArray()
val declineCodeValues = StripeDeclineCode.Code.entries.map { it.code }.toTypedArray()
val selectedDeclineCodeValue = state.selectedStripeDeclineCode?.code ?: ""
val declineCodeEnabled = state.selectedBadge?.isSubscription() == true
Rows.RadioListRow(
text = "Charge Failure",
labels = declineCodeLabels,
values = declineCodeValues,
selectedValue = selectedDeclineCodeValue,
onSelected = callback::onSelectedDeclineCodeChanged,
enabled = declineCodeEnabled
)
Spacer(modifier = Modifier.height(16.dp))
Buttons.LargeTonal(
onClick = callback::onSaveClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(text = "Save and Finish")
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = callback::onClearClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(text = "Clear")
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Callback interface for [InternalDonorErrorConfigurationScreen] interactions.
*/
interface InternalDonorErrorConfigurationScreenCallback {
fun onNavigationClick()
fun onSelectedBadgeChanged(badgeId: String)
fun onSelectedCancellationChanged(status: String)
fun onSelectedDeclineCodeChanged(code: String)
fun onSaveClick()
fun onClearClick()
object Empty : InternalDonorErrorConfigurationScreenCallback {
override fun onNavigationClick() = Unit
override fun onSelectedBadgeChanged(badgeId: String) = Unit
override fun onSelectedCancellationChanged(status: String) = Unit
override fun onSelectedDeclineCodeChanged(code: String) = Unit
override fun onSaveClick() = Unit
override fun onClearClick() = Unit
}
}
@DayNightPreviews
@Composable
private fun InternalDonorErrorConfigurationScreenPreview() {
Previews.Preview {
InternalDonorErrorConfigurationScreen(
state = InternalDonorErrorConfigurationState(
badges = listOf(
Badge(
id = "test1",
category = Badge.Category.Testing,
name = "Test Badge 1",
description = "Test description 1",
imageUrl = android.net.Uri.EMPTY,
imageDensity = "xxxhdpi",
expirationTimestamp = 0L,
visible = true,
duration = 0L
),
Badge(
id = "test2",
category = Badge.Category.Testing,
name = "Test Badge 2",
description = "Test description 2",
imageUrl = android.net.Uri.EMPTY,
imageDensity = "xxxhdpi",
expirationTimestamp = 0L,
visible = true,
duration = 0L
)
),
selectedBadge = null,
selectedUnexpectedSubscriptionCancellation = null,
selectedStripeDeclineCode = null
),
callback = InternalDonorErrorConfigurationScreenCallback.Empty
)
}
}

View File

@@ -1,15 +1,16 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
@@ -17,58 +18,27 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscr
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Locale
import kotlin.concurrent.withLock
class InternalDonorErrorConfigurationViewModel : ViewModel() {
private val store = RxStore(InternalDonorErrorConfigurationState())
private val disposables = CompositeDisposable()
private val store = MutableStateFlow(InternalDonorErrorConfigurationState())
val state: Flowable<InternalDonorErrorConfigurationState> = store.stateFlowable
val state: StateFlow<InternalDonorErrorConfigurationState> = store
init {
val giftBadges: Single<List<Badge>> = Single
.fromCallable {
AppDependencies.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
.flatMap { it.flattenResult() }
.map { it.getGiftBadges() }
.subscribeOn(Schedulers.io())
viewModelScope.launch(SignalDispatchers.IO) {
val configuration = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult().successOrNull() ?: return@launch
val giftBadges = configuration.getGiftBadges()
val boostBadges = configuration.getBoostBadges()
val subscriptionBadges = configuration.getSubscriptionLevels().values.map { Badges.fromServiceBadge(it.badge) }
val boostBadges: Single<List<Badge>> = Single
.fromCallable {
AppDependencies.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
.flatMap { it.flattenResult() }
.map { it.getBoostBadges() }
.subscribeOn(Schedulers.io())
val subscriptionBadges: Single<List<Badge>> = Single
.fromCallable {
AppDependencies.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
.flatMap { it.flattenResult() }
.map { config -> config.getSubscriptionLevels().values.map { Badges.fromServiceBadge(it.badge) } }
.subscribeOn(Schedulers.io())
disposables += Single.zip(giftBadges, boostBadges, subscriptionBadges) { g, b, s ->
g + b + s
}.subscribe { badges ->
store.update { it.copy(badges = badges) }
store.update { it.copy(badges = giftBadges + boostBadges + subscriptionBadges) }
}
}
override fun onCleared() {
disposables.clear()
store.dispose()
}
fun setSelectedBadge(badgeIndex: Int) {
store.update {
it.copy(selectedBadge = if (badgeIndex in it.badges.indices) it.badges[badgeIndex] else null)
@@ -100,7 +70,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
}
fun save(): Completable {
val snapshot = store.state
val snapshot = store.value
val saveState = Completable.fromAction {
InAppPaymentSubscriberRecord.Type.DONATION.lock.withLock {
when {