Migrate donation gateway sheet to compose.

This commit is contained in:
Alex Hart
2026-04-30 09:43:44 -03:00
committed by Greyson Parrelli
parent 9e7477bbeb
commit 7fc4ec3006
21 changed files with 835 additions and 294 deletions
+2 -5
View File
@@ -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)
@@ -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
@@ -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
@@ -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) {
@@ -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<GooglePayComponent>().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))
}
}
@@ -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"))
)
}
}
@@ -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
}
@@ -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<InAppPaymentTable.InAppPayment> {
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(
@@ -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"
}
@@ -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>(GatewaySelectorState.Loading)
private val store = MutableStateFlow<GatewaySelectorState>(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<InAppPaymentTable.InAppPayment> {
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)
}
}
@@ -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
@@ -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<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(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))
}
}
@@ -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<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled
}
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
override fun bind(model: Model) {
binding.paypalButton.isEnabled = model.isEnabled
binding.paypalButton.setOnClickListener {
binding.paypalButton.isEnabled = false
model.onClick()
}
}
}
}
@@ -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
@@ -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<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder<Model>(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)
}
}
@@ -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
@@ -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)
)
}
}
@@ -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()
}
}
+5
View File
@@ -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)
@@ -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)
)
}
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="donate_with_google_pay_background_color">#000000</color>
<color name="donate_with_google_pay_content_overlay">#7FFFFFFF</color>
</resources>