diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationFragment.kt index 4169f493a6..aeb43d63a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationFragment.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt index 2cfbb0eb60..e2a141ffee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt @@ -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 = store.stateFlowable + val state: StateFlow = store init { - val giftBadges: Single> = 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> = Single - .fromCallable { - AppDependencies.donationsService - .getDonationsConfiguration(Locale.getDefault()) - } - .flatMap { it.flattenResult() } - .map { it.getBoostBadges() } - .subscribeOn(Schedulers.io()) - - val subscriptionBadges: Single> = 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 { 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 6705214a15..1d0e482154 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 @@ -60,6 +60,7 @@ import org.signal.core.ui.compose.Dialogs.PermissionRationaleDialog import org.signal.core.ui.compose.Dialogs.SimpleAlertDialog import org.signal.core.ui.compose.Dialogs.SimpleMessageDialog import org.signal.core.ui.compose.theme.SignalTheme +import kotlin.math.max object Dialogs { @@ -398,7 +399,7 @@ object Dialogs { LazyColumn( modifier = Modifier.padding(top = 24.dp, bottom = 16.dp), state = rememberLazyListState( - initialFirstVisibleItemIndex = selectedIndex + initialFirstVisibleItemIndex = max(selectedIndex, 0) ) ) { items( 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 7b5844f1b5..8d8470513a 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 @@ -195,7 +195,8 @@ object Rows { enabled = enabled, onClick = { displayDialog = true - } + }, + modifier = Modifier.alpha(if (enabled) 1f else DISABLED_ALPHA) ) if (displayDialog) {