mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-09 01:38:53 +01:00
Migrate donation gateway sheet to compose.
This commit is contained in:
committed by
Greyson Parrelli
parent
9e7477bbeb
commit
7fc4ec3006
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+39
-161
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+366
@@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+7
-4
@@ -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(
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+27
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-32
@@ -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))
|
||||
}
|
||||
}
|
||||
-28
@@ -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
-1
@@ -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
|
||||
+9
-47
@@ -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
-1
@@ -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
|
||||
+85
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+171
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user