diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd3e04f071..652e80e574 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -619,11 +619,7 @@ dependencies { implementation(project(":lib:apng")) implementation(libs.androidx.fragment.ktx) - implementation(libs.androidx.appcompat) { - version { - strictly("1.6.1") - } - } + implementation(libs.androidx.appcompat) implementation(libs.androidx.window.window) implementation(libs.androidx.window.java) implementation(libs.androidx.recyclerview) @@ -743,6 +739,7 @@ dependencies { } testImplementation(testLibs.conscrypt.openjdk.uber) testImplementation(testLibs.mockk) + testImplementation(testFixtures(project(":core:ui"))) testImplementation(testFixtures(project(":lib:libsignal-service"))) testImplementation(testLibs.espresso.core) testImplementation(testLibs.kotlinx.coroutines.test) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt index cf7bd4dc6a..7ad4035bf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt @@ -10,8 +10,8 @@ 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.app.subscription.models.CurrencySelection -import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle import org.thoughtcrime.securesms.components.settings.models.SplashImage diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 376c3fed25..9bc8ea0b37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -34,9 +34,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet -import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection -import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt index 04c0fc51d9..43a57529e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt @@ -46,8 +46,7 @@ sealed interface GatewayOrderStrategy { } companion object { - fun getStrategy(): GatewayOrderStrategy { - val self = Recipient.self() + fun getStrategy(self: Recipient = Recipient.self()): GatewayOrderStrategy { val e164 = self.e164.orNull() ?: return Default return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index ce76981f60..2b54580b46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -1,34 +1,28 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import io.reactivex.rxjava3.kotlin.subscribeBy -import org.signal.core.util.concurrent.LifecycleDisposable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment import org.signal.core.util.dp import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.Badges -import org.thoughtcrime.securesms.badges.models.BadgeDisplay112 import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter -import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton -import org.thoughtcrime.securesms.components.settings.app.subscription.models.IdealWeroButton -import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.payments.FiatMoneyUtil -import org.thoughtcrime.securesms.payments.currency.CurrencyUtil import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.viewModel import org.signal.core.ui.R as CoreUiR @@ -36,172 +30,56 @@ import org.signal.core.ui.R as CoreUiR /** * Entry point to capturing the necessary payment token to pay for a donation */ -class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() { - - private val lifecycleDisposable = LifecycleDisposable() +class GatewaySelectorBottomSheet : ComposeBottomSheetDialogFragment() { private val args: GatewaySelectorBottomSheetArgs by navArgs() + override val peekHeightPercentage: Float = 1f private val viewModel: GatewaySelectorViewModel by viewModel { GatewaySelectorViewModel(args, requireListener().googlePayRepository) } - override fun bindAdapter(adapter: DSLSettingsAdapter) { - BadgeDisplay112.register(adapter) - GooglePayButton.register(adapter) - PayPalButton.register(adapter) - IndeterminateLoadingCircle.register(adapter) - IdealWeroButton.register(adapter) + @Composable + override fun SheetContent() { + val state by viewModel.state.collectAsStateWithLifecycle() - lifecycleDisposable.bindTo(viewLifecycleOwner) - - lifecycleDisposable += viewModel.state.subscribe { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } + GatewaySelectorBottomSheetContent(state, onEvent = this::onEvent) } - private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration { - return when (state) { - GatewaySelectorState.Loading -> { - configure { - space(16.dp) - customPref(IndeterminateLoadingCircle) - space(16.dp) + private fun onEvent(event: GatewaySelectorBottomSheetEvent) { + when (event) { + GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED -> { + setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.GOOGLE_PAY) + } + + GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED -> { + setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.PAYPAL) + } + + GatewaySelectorBottomSheetEvent.SEPA_SELECTED -> { + if (viewModel.checkIsSepaPaymentValidAmount()) { + setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.SEPA_DEBIT) + } else { + findNavController().popBackStack() + setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to viewModel.getSepaMaximum())) } } - is GatewaySelectorState.Ready -> { - configure { - customPref( - BadgeDisplay112.Model( - badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) }, - withDisplayText = false - ) - ) - space(12.dp) + GatewaySelectorBottomSheetEvent.IDEAL_SELECTED -> { + setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.IDEAL) + } - presentTitleAndSubtitle(requireContext(), state.inAppPayment) - - space(16.dp) - - state.gatewayOrderStrategy.orderedGateways.forEach { gateway -> - when (gateway) { - InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.") - InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state) - InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state) - InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state) - InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state) - InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state) - InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.") - } - } - - space(16.dp) - } + GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED -> { + setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.CARD) } } } - private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState.Ready) { - if (state.isGooglePayAvailable) { - space(16.dp) - - customPref( - GooglePayButton.Model( - isEnabled = true, - onClick = { - lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY) - .subscribeBy { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it)) - } - } - ) - ) - } - } - - private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState.Ready) { - if (state.isPayPalAvailable) { - space(16.dp) - - customPref( - PayPalButton.Model( - onClick = { - lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL) - .subscribeBy { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it)) - } - }, - isEnabled = true - ) - ) - } - } - - private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState.Ready) { - if (state.isCreditCardAvailable) { - space(16.dp) - - primaryButton( - text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card), - icon = DSLSettingsIcon.from(R.drawable.credit_card, CoreUiR.color.signal_colorOnCustom), - disableOnClick = true, - onClick = { - lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD) - .subscribeBy { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it)) - } - } - ) - } - } - - private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState.Ready) { - if (state.isSEPADebitAvailable) { - space(16.dp) - - tonalButton( - text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer), - icon = DSLSettingsIcon.from(R.drawable.bank_transfer), - disableOnClick = true, - onClick = { - val price = state.inAppPayment.data.amount!!.toFiatMoney() - if (state.sepaEuroMaximum != null && - price.currency == CurrencyUtil.EURO && - price.amount > state.sepaEuroMaximum.amount - ) { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount)) - } else { - lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT) - .subscribeBy { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it)) - } - } - } - ) - } - } - - private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState.Ready) { - if (state.isIDEALAvailable) { - space(16.dp) - - customPref( - IdealWeroButton.Model( - onClick = { - lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL) - .subscribeBy { - findNavController().popBackStack() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it)) - } - } - ) - ) + private fun setPaymentMethodAndDismiss(type: InAppPaymentData.PaymentMethodType) { + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + val inAppPayment = viewModel.updateInAppPaymentMethod(type) + findNavController().popBackStack() + setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to inAppPayment)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContent.kt new file mode 100644 index 0000000000..53097906dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContent.kt @@ -0,0 +1,366 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.BottomSheets +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.horizontalGutters +import org.signal.core.util.money.FiatMoney +import org.signal.donations.DonateWithGooglePayButton +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112 +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.IdealWeroButton +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.PayPalButton +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList +import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import org.thoughtcrime.securesms.recipients.Recipient +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun GatewaySelectorBottomSheetContent( + state: GatewaySelectorState, + onEvent: (GatewaySelectorBottomSheetEvent) -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .testTag(GatewaySelectorTestTags.CONTAINER) + .verticalScroll(scrollState) + .horizontalGutters() + .fillMaxWidth() + ) { + BottomSheets.Handle() + + when (state) { + GatewaySelectorState.Loading -> Loading() + is GatewaySelectorState.Ready -> Ready(state, onEvent) + } + } +} + +@Composable +private fun Loading() { + CircularProgressIndicator( + modifier = Modifier.padding(vertical = 16.dp) + ) +} + +@Composable +private fun Ready(state: GatewaySelectorState.Ready, onEvent: (GatewaySelectorBottomSheetEvent) -> Unit) { + BadgeImage112( + badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) }, + modifier = Modifier.size(112.dp) + ) + + Spacer(modifier = Modifier.size(12.dp)) + + TitleAndSubtitle(state.inAppPayment) + + Spacer(modifier = Modifier.size(16.dp)) + + var isGatewaySelected by remember { mutableStateOf(false) } + val onGatewaySelected: (GatewaySelectorBottomSheetEvent) -> Unit = remember(onEvent) { + { + if (!isGatewaySelected) { + isGatewaySelected = true + onEvent(it) + } + } + } + + state.gatewayOrderStrategy.orderedGateways.forEach { + when (it) { + InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.") + InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> { + if (state.isGooglePayAvailable) { + DonateWithGooglePayButton( + onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED) }, + enabled = !isGatewaySelected, + modifier = Modifier + .testTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON) + .padding(top = 16.dp) + .fillMaxWidth() + .height(44.dp) + ) + } + } + + InAppPaymentData.PaymentMethodType.CARD -> { + if (state.isCreditCardAvailable) { + Buttons.LargePrimary( + onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED) }, + enabled = !isGatewaySelected, + modifier = Modifier + .testTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON) + .padding(top = 16.dp) + .fillMaxWidth() + .height(44.dp) + ) { + Row( + horizontalArrangement = spacedBy(8.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.credit_card), + contentDescription = null + ) + + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__credit_or_debit_card) + ) + } + } + } + } + + InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> { + if (state.isSEPADebitAvailable) { + Buttons.LargeTonal( + onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.SEPA_SELECTED) }, + enabled = !isGatewaySelected, + modifier = Modifier + .testTag(GatewaySelectorTestTags.SEPA_BUTTON) + .padding(top = 16.dp) + .fillMaxWidth() + .height(44.dp) + ) { + Row( + horizontalArrangement = spacedBy(8.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.bank_transfer), + contentDescription = null + ) + + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__bank_transfer) + ) + } + } + } + } + + InAppPaymentData.PaymentMethodType.IDEAL -> { + if (state.isIDEALAvailable) { + IdealWeroButton( + onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.IDEAL_SELECTED) }, + enabled = !isGatewaySelected, + modifier = Modifier + .testTag(GatewaySelectorTestTags.IDEAL_BUTTON) + .padding(top = 16.dp) + .height(44.dp) + .fillMaxWidth() + ) + } + } + + InAppPaymentData.PaymentMethodType.PAYPAL -> { + if (state.isPayPalAvailable) { + PayPalButton( + enabled = !isGatewaySelected, + onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED) }, + modifier = Modifier + .testTag(GatewaySelectorTestTags.PAYPAL_BUTTON) + .padding(top = 16.dp) + .height(44.dp) + .fillMaxWidth() + ) + } + } + + InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.") + } + } + + Spacer(modifier = Modifier.size(16.dp)) +} + +@DayNightPreviews +@Composable +private fun GatewaySelectorBottomSheetContentLoadingPreview() { + Previews.BottomSheetContentPreview { + GatewaySelectorBottomSheetContent( + state = GatewaySelectorState.Loading, + onEvent = {} + ) + } +} + +@Composable +private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) { + when (inAppPayment.type) { + InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN") + InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment) + InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment) + InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment) + InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported") + } +} + +@Composable +private fun RecurringDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) { + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, rememberFormattedAmount(inAppPayment)), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 6.dp) + ) + + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun OneTimeDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) { + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 6.dp) + ) + + Text( + text = pluralStringResource(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun OneTimeGiftTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) { + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 6.dp) + ) + + Text( + text = stringResource(R.string.GatewaySelectorBottomSheet__donate_for_a_friend), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun rememberFormattedAmount(inAppPayment: InAppPaymentTable.InAppPayment): String { + val resources = LocalResources.current + return remember(inAppPayment.data.amount) { + FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney()) + } +} + +@DayNightPreviews +@Composable +private fun GatewaySelectorBottomSheetContentReadyOneTimeDonationPreview() { + Previews.BottomSheetContentPreview { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_DONATION), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun GatewaySelectorBottomSheetContentReadyRecurringDonationPreview() { + Previews.BottomSheetContentPreview { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.RECURRING_DONATION), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun GatewaySelectorBottomSheetContentReadyOneTimeGiftDonationPreview() { + Previews.BottomSheetContentPreview { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = {} + ) + } +} + +@Composable +@VisibleForTesting +fun rememberGatewaySelectorBottomSheetContentPreviewState(type: InAppPaymentType): GatewaySelectorState.Ready { + return remember { + GatewaySelectorState.Ready( + inAppPayment = InAppPaymentTable.InAppPayment( + id = InAppPaymentTable.InAppPaymentId(1), + type = type, + state = InAppPaymentTable.State.CREATED, + insertedAt = 1.milliseconds, + updatedAt = 1.milliseconds, + notified = true, + subscriberId = null, + endOfPeriod = 0.milliseconds, + data = InAppPaymentData( + badge = BadgeList.Badge( + name = type.name.lowercase() + ), + amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue()) + ) + ), + gatewayOrderStrategy = GatewayOrderStrategy.getStrategy( + self = Recipient( + isResolving = false, + e164Value = "+15555555555" + ) + ), + isGooglePayAvailable = true, + isPayPalAvailable = true, + isCreditCardAvailable = true, + isSEPADebitAvailable = true, + isIDEALAvailable = true, + sepaEuroMaximum = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetEvent.kt new file mode 100644 index 0000000000..7df3ab054e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +enum class GatewaySelectorBottomSheetEvent { + GOOGLE_PAY_SELECTED, + PAYPAL_SELECTED, + SEPA_SELECTED, + IDEAL_SELECTED, + CREDIT_CARD_SELECTED +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt index 24363a033b..1902a6852f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt @@ -1,8 +1,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.signal.core.util.money.FiatMoney -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase @@ -35,8 +36,8 @@ object GatewaySelectorRepository { } } - fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): Single { - return Single.fromCallable { + suspend fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment { + return withContext(Dispatchers.IO) { SignalDatabase.inAppPayments.update( inAppPayment.copy( data = inAppPayment.data.copy( @@ -44,7 +45,9 @@ object GatewaySelectorRepository { ) ) ) - }.flatMap { InAppPaymentsRepository.requireInAppPayment(inAppPayment.id) } + + SignalDatabase.inAppPayments.getById(inAppPayment.id) ?: throw Exception("Not found.") + } } data class GatewayConfiguration( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorTestTags.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorTestTags.kt new file mode 100644 index 0000000000..ad6a56e010 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorTestTags.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +object GatewaySelectorTestTags { + const val CONTAINER = "container" + const val GOOGLE_PAY_BUTTON = "google_pay_button" + const val PAYPAL_BUTTON = "paypal_button" + const val CREDIT_CARD_BUTTON = "credit_card_button" + const val SEPA_BUTTON = "sepa_button" + const val IDEAL_BUTTON = "ideal_button" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index ea43e7d5f8..a3b6abd9c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -1,29 +1,33 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway import androidx.lifecycle.ViewModel -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.util.rx.RxStore +import org.thoughtcrime.securesms.payments.currency.CurrencyUtil +import java.math.BigDecimal class GatewaySelectorViewModel( args: GatewaySelectorBottomSheetArgs, repository: GooglePayRepository ) : ViewModel() { - private val store = RxStore(GatewaySelectorState.Loading) + private val store = MutableStateFlow(GatewaySelectorState.Loading) private val disposables = CompositeDisposable() - val state = store.stateFlowable + val state = store.asStateFlow() init { val inAppPayment = InAppPaymentsRepository.requireInAppPayment(args.inAppPaymentId) @@ -48,13 +52,28 @@ class GatewaySelectorViewModel( } override fun onCleared() { - store.dispose() disposables.clear() } - fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single { - val state = store.state as GatewaySelectorState.Ready + fun getSepaMaximum(): BigDecimal { + val state = store.value as GatewaySelectorState.Ready + return state.sepaEuroMaximum!!.amount + } - return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread()) + fun checkIsSepaPaymentValidAmount(): Boolean { + val state = store.value as GatewaySelectorState.Ready + + val price = state.inAppPayment.data.amount!!.toFiatMoney() + return !( + state.sepaEuroMaximum != null && + price.currency == CurrencyUtil.EURO && + price.amount > state.sepaEuroMaximum.amount + ) + } + + suspend fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment { + val state = store.value as GatewaySelectorState.Ready + + return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 10977a56f5..26791b4d83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity -import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle import org.thoughtcrime.securesms.database.InAppPaymentTable diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt deleted file mode 100644 index 54dd0648fc..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.models - -import android.view.View -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder - -object GooglePayButton { - - class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel(isEnabled = isEnabled) { - override fun areItemsTheSame(newItem: Model): Boolean = true - } - - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val googlePayButton: View = findViewById(R.id.googlepay_button) - - override fun bind(model: Model) { - googlePayButton.isEnabled = model.isEnabled - googlePayButton.setOnClickListener { - googlePayButton.isEnabled = false - model.onClick() - } - } - } - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref)) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt deleted file mode 100644 index 035fb5e67f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.models - -import org.thoughtcrime.securesms.databinding.PaypalButtonBinding -import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory -import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel - -object PayPalButton { - fun register(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, PaypalButtonBinding::inflate)) - } - - class Model(val onClick: () -> Unit, val isEnabled: Boolean) : MappingModel { - override fun areItemsTheSame(newItem: Model): Boolean = true - override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled - } - - class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder(binding) { - override fun bind(model: Model) { - binding.paypalButton.isEnabled = model.isEnabled - binding.paypalButton.setOnClickListener { - binding.paypalButton.isEnabled = false - model.onClick() - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/CurrencySelection.kt similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/CurrencySelection.kt index 02da2fd88c..02ea5fb127 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/CurrencySelection.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.models +package org.thoughtcrime.securesms.components.settings.app.subscription.ui import android.view.View import android.widget.TextView diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/IdealWeroButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/IdealWeroButton.kt similarity index 50% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/IdealWeroButton.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/IdealWeroButton.kt index a8468f2a7b..9d0445d21b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/IdealWeroButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/IdealWeroButton.kt @@ -1,19 +1,11 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.models +package org.thoughtcrime.securesms.components.settings.app.subscription.ui import androidx.compose.foundation.Image import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.material3.ButtonColors import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -21,49 +13,19 @@ import androidx.compose.ui.unit.dp 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.horizontalGutters import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter - -/** - * DSL Ideal | Wero button for the payments gateway. - */ -object IdealWeroButton { - - @Stable - class Model(val onClick: () -> Unit) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean = true - } - - class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder(itemView) { - @Composable - override fun Content(model: Model) { - IdealWeroButton(model) - } - } - - fun register(adapter: MappingAdapter) { - DSLComposePreference.register(adapter) { ViewHolder(it) } - } -} @Composable -private fun IdealWeroButton(model: IdealWeroButton.Model) { - var enabled by remember { mutableStateOf(true) } - +fun IdealWeroButton( + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier +) { Buttons.LargeTonal( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - onClick = { - enabled = false - model.onClick() - }, + onClick = onClick, enabled = enabled, - modifier = Modifier - .height(44.dp) - .horizontalGutters() - .fillMaxWidth(), + modifier = modifier, colors = ButtonColors( containerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer), contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer), @@ -82,6 +44,6 @@ private fun IdealWeroButton(model: IdealWeroButton.Model) { @Composable private fun IdealWeroButtonPreview() { Previews.Preview { - IdealWeroButton(model = remember { IdealWeroButton.Model(onClick = {}) }) + IdealWeroButton(onClick = {}, enabled = true) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/NetworkFailure.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/NetworkFailure.kt similarity index 98% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/NetworkFailure.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/NetworkFailure.kt index 092b098c87..d21ec437e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/NetworkFailure.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/NetworkFailure.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.models +package org.thoughtcrime.securesms.components.settings.app.subscription.ui import android.view.View import com.google.android.material.button.MaterialButton diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/PayPalButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/PayPalButton.kt new file mode 100644 index 0000000000..a4e06bfa21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/ui/PayPalButton.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +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.horizontalGutters +import org.thoughtcrime.securesms.R + +@Composable +fun PayPalButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val overlayColor = colorResource(org.signal.core.ui.R.color.signal_light_colorTransparent3) + Buttons.LargeTonal( + onClick = onClick, + enabled = enabled, + contentPadding = PaddingValues.Zero, + modifier = modifier.drawWithContent { + drawContent() + + if (!enabled) { + drawRoundRect( + color = overlayColor, + cornerRadius = CornerRadius(500f, 500f) + ) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFF6C757), + disabledContainerColor = Color(0xFFF6C757) + ) + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.paypal), + contentDescription = stringResource(R.string.BackupsTypeSettingsFragment__paypal) + ) + } +} + +@DayNightPreviews +@Composable +fun PayPalButtonPreview() { + Previews.Preview { + PayPalButton( + enabled = true, + onClick = {}, + modifier = Modifier + .horizontalGutters() + .fillMaxWidth() + .height(44.dp) + ) + } +} + +@DayNightPreviews +@Composable +fun PayPalButtonDisabledPreview() { + Previews.Preview { + PayPalButton( + enabled = false, + onClick = {}, + modifier = Modifier + .horizontalGutters() + .fillMaxWidth() + .height(44.dp) + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContentTest.kt new file mode 100644 index 0000000000..e183c16afb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheetContentTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import android.app.Application +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ApplicationProvider +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.donations.InAppPaymentType + +/** + * Tests for GatewaySelectorBottomSheetContent that validate event emissions. + * Uses Robolectric to run fast JUnit tests without an emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class GatewaySelectorBottomSheetContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + @Test + fun `when Google Pay is clicked, GOOGLE_PAY_SELECTED event is emitted`() { + // Given + var emittedEvent: GatewaySelectorBottomSheetEvent? = null + + composeTestRule.setContent { + SignalTheme { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON)) + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick() + + // Then + assertAllButtonsAreDisabled() + assert(emittedEvent == GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED) + } + + @Test + fun `when PayPal is clicked, PAYPAL_SELECTED event is emitted`() { + // Given + var emittedEvent: GatewaySelectorBottomSheetEvent? = null + + composeTestRule.setContent { + SignalTheme { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.PAYPAL_BUTTON)) + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.PAYPAL_BUTTON).performClick() + + // Then + assertAllButtonsAreDisabled() + assert(emittedEvent == GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED) + } + + @Test + fun `when iDEAL is clicked, IDEAL_SELECTED event is emitted`() { + // Given + var emittedEvent: GatewaySelectorBottomSheetEvent? = null + + composeTestRule.setContent { + SignalTheme { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.IDEAL_BUTTON)) + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.IDEAL_BUTTON).performClick() + + // Then + assertAllButtonsAreDisabled() + assert(emittedEvent == GatewaySelectorBottomSheetEvent.IDEAL_SELECTED) + } + + @Test + fun `when SEPA is clicked, SEPA_SELECTED event is emitted`() { + // Given + var emittedEvent: GatewaySelectorBottomSheetEvent? = null + + composeTestRule.setContent { + SignalTheme { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.SEPA_BUTTON)) + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.SEPA_BUTTON).performClick() + + // Then + assertAllButtonsAreDisabled() + assert(emittedEvent == GatewaySelectorBottomSheetEvent.SEPA_SELECTED) + } + + @Test + fun `when Credit Card is clicked, CREDIT_CARD_SELECTED event is emitted`() { + // Given + var emittedEvent: GatewaySelectorBottomSheetEvent? = null + + composeTestRule.setContent { + SignalTheme { + GatewaySelectorBottomSheetContent( + state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT), + onEvent = { event -> + emittedEvent = event + } + ) + } + } + + // When + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CONTAINER).performScrollToNode(hasTestTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)) + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON).performClick() + + // Then + assertAllButtonsAreDisabled() + assert(emittedEvent == GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED) + } + + private fun assertAllButtonsAreDisabled() { + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).assertIsNotEnabled() + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON).assertIsNotEnabled() + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.IDEAL_BUTTON).assertIsNotEnabled() + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.SEPA_BUTTON).assertIsNotEnabled() + composeTestRule.onNodeWithTag(GatewaySelectorTestTags.PAYPAL_BUTTON).assertIsNotEnabled() + } +} diff --git a/lib/donations/build.gradle.kts b/lib/donations/build.gradle.kts index 6bf25c854d..9aac031f9a 100644 --- a/lib/donations/build.gradle.kts +++ b/lib/donations/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("signal-library") id("kotlin-parcelize") + alias(libs.plugins.compose.compiler) } android { @@ -8,6 +9,7 @@ android { buildFeatures { buildConfig = true + compose = true } defaultConfig { @@ -17,6 +19,9 @@ android { dependencies { implementation(project(":core:util")) + implementation(project(":core:ui")) + + implementation(platform(libs.androidx.compose.bom)) implementation(libs.kotlin.reflect) implementation(libs.jackson.module.kotlin) diff --git a/lib/donations/src/main/java/org/signal/donations/DonateWithGooglePayButton.kt b/lib/donations/src/main/java/org/signal/donations/DonateWithGooglePayButton.kt new file mode 100644 index 0000000000..df92613bad --- /dev/null +++ b/lib/donations/src/main/java/org/signal/donations/DonateWithGooglePayButton.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews + +/** + * Compose "Donate with Google Pay" button utilizing the same styling as the layout. + */ +@Composable +fun DonateWithGooglePayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val contentOverlay = colorResource(R.color.donate_with_google_pay_content_overlay) + + Buttons.LargeTonal( + enabled = enabled, + onClick = onClick, + modifier = modifier.drawWithContent { + drawContent() + + if (!enabled) { + drawRoundRect( + color = contentOverlay, + cornerRadius = CornerRadius(500f, 500f) + ) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.donate_with_google_pay_background_color), + disabledContainerColor = colorResource(R.color.donate_with_google_pay_background_color) + ) + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.donate_with_googlepay_button_content), + contentDescription = stringResource(R.string.donate_with_googlepay_button_content_description) + ) + } +} + +@DayNightPreviews +@Composable +private fun DonateWithGooglePayButtonPreview() { + Previews.Preview { + DonateWithGooglePayButton( + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + ) + } +} + +@DayNightPreviews +@Composable +private fun DonateWithGooglePayButtonDisabledPreview() { + Previews.Preview { + DonateWithGooglePayButton( + onClick = {}, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + ) + } +} diff --git a/lib/donations/src/main/res/values/colors.xml b/lib/donations/src/main/res/values/colors.xml index 925d9541dc..646f05a640 100644 --- a/lib/donations/src/main/res/values/colors.xml +++ b/lib/donations/src/main/res/values/colors.xml @@ -1,4 +1,5 @@ #000000 + #7FFFFFFF \ No newline at end of file