Perform client side checks on name and email for donation flows.

This commit is contained in:
Cody Henthorne
2023-11-30 10:46:34 -05:00
parent 1005be006f
commit 0b0c54d874
8 changed files with 204 additions and 43 deletions

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
object BankDetailsValidator {
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
fun validName(name: String): Boolean {
return name.length >= 2
}
fun validEmail(email: String): Boolean {
return email.length >= 3 && email.matches(EMAIL_REGEX)
}
}

View File

@@ -64,6 +64,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -133,7 +134,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
setDisplayFindAccountInfoSheet = viewModel::setDisplayFindAccountInfoSheet,
onLearnMoreClick = this::onLearnMoreClick,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
onFocusChanged = viewModel::onFocusChanged,
donateLabel = donateLabel
)
}
@@ -186,7 +187,7 @@ private fun BankTransferDetailsContentPreview() {
setDisplayFindAccountInfoSheet = {},
onLearnMoreClick = {},
onDonateClick = {},
onIBANFocusChanged = {},
onFocusChanged = { _, _ -> },
donateLabel = "Donate $5/month"
)
}
@@ -202,7 +203,7 @@ private fun BankTransferDetailsContent(
setDisplayFindAccountInfoSheet: (Boolean) -> Unit,
onLearnMoreClick: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
onFocusChanged: (Field, Boolean) -> Unit,
donateLabel: String
) {
Scaffolds.Settings(
@@ -275,7 +276,7 @@ private fun BankTransferDetailsContent(
.fillMaxWidth()
.padding(top = 12.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.onFocusChanged { onFocusChanged(Field.IBAN, it.hasFocus) }
.focusRequester(focusRequester)
)
}
@@ -294,11 +295,17 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
supportingText = {},
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
)
}
@@ -316,11 +323,17 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
supportingText = {},
isError = state.showEmailError(),
supportingText = {
if (state.showEmailError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__invalid_email_address))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
)
}

View File

@@ -6,15 +6,26 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankDetailsValidator
data class BankTransferDetailsState(
val name: String = "",
val nameFocusState: FocusState = FocusState.NOT_FOCUSED,
val iban: String = "",
val email: String = "",
val emailFocusState: FocusState = FocusState.NOT_FOCUSED,
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID,
val displayFindAccountInfoSheet: Boolean = false
) {
val canProceed = name.isNotBlank() && email.isNotBlank() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
val canProceed = BankDetailsValidator.validName(name) && BankDetailsValidator.validEmail(email) && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
fun showNameError(): Boolean {
return nameFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validName(name)
}
fun showEmailError(): Boolean {
return emailFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validEmail(email)
}
fun asSEPADebitData(): StripeApi.SEPADebitData {
return StripeApi.SEPADebitData(
@@ -23,4 +34,10 @@ data class BankTransferDetailsState(
email = email.trim()
)
}
enum class FocusState {
NOT_FOCUSED,
FOCUSED,
LOST_FOCUS
}
}

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.t
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsState.FocusState
class BankTransferDetailsViewModel : ViewModel() {
@@ -30,12 +31,32 @@ class BankTransferDetailsViewModel : ViewModel() {
)
}
fun onIBANFocusChanged(isFocused: Boolean) {
fun onFocusChanged(field: Field, isFocused: Boolean) {
when (field) {
Field.IBAN -> {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
}
Field.NAME -> {
if (isFocused && internalState.value.nameFocusState == FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = FocusState.FOCUSED)
} else if (!isFocused && internalState.value.nameFocusState == FocusState.FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = FocusState.LOST_FOCUS)
}
}
Field.EMAIL -> {
if (isFocused && internalState.value.emailFocusState == FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = FocusState.FOCUSED)
} else if (!isFocused && internalState.value.emailFocusState == FocusState.FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = FocusState.LOST_FOCUS)
}
}
}
}
fun onIBANChanged(iban: String) {
internalState.value = internalState.value.copy(
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
@@ -48,4 +69,10 @@ class BankTransferDetailsViewModel : ViewModel() {
email = email
)
}
enum class Field {
IBAN,
NAME,
EMAIL
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.t
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -28,6 +29,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -41,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
@@ -62,12 +64,14 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment for inputting necessary bank transfer information for iDEAL donation
@@ -75,7 +79,9 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
private val args: IdealTransferDetailsFragmentArgs by navArgs()
private val viewModel: IdealTransferDetailsViewModel by viewModels()
private val viewModel: IdealTransferDetailsViewModel by viewModel {
IdealTransferDetailsViewModel(args.request.donateToSignalType == DonateToSignalType.MONTHLY)
}
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
@@ -127,14 +133,24 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
}
}
val idealDirections = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
R.string.IdealTransferDetailsFragment__enter_your_bank
} else {
R.string.IdealTransferDetailsFragment__enter_your_bank_details_one_time
}
}
IdealTransferDetailsContent(
state = state,
idealDirections = idealDirections,
donateLabel = donateLabel,
onNavigationClick = { findNavController().popBackStack() },
onLearnMoreClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()) },
onSelectBankClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionIdealTransferDetailsFragmentToIdealTransferBankSelectionDialogFragment()) },
onNameChanged = viewModel::onNameChanged,
onEmailChanged = viewModel::onEmailChanged,
onFocusChanged = viewModel::onFocusChanged,
onDonateClick = this::onDonateClick
)
}
@@ -171,13 +187,15 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
@Composable
private fun IdealTransferDetailsContentPreview() {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(),
state = IdealTransferDetailsState(isMonthly = true),
idealDirections = R.string.IdealTransferDetailsFragment__enter_your_bank,
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onFocusChanged = { _, _ -> },
onDonateClick = {}
)
}
@@ -185,12 +203,14 @@ private fun IdealTransferDetailsContentPreview() {
@Composable
private fun IdealTransferDetailsContent(
state: IdealTransferDetailsState,
@StringRes idealDirections: Int,
donateLabel: String,
onNavigationClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSelectBankClick: () -> Unit,
onNameChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFocusChanged: (Field, Boolean) -> Unit,
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
@@ -211,7 +231,7 @@ private fun IdealTransferDetailsContent(
) {
item {
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
val fullString = stringResource(id = idealDirections, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
@@ -248,14 +268,21 @@ private fun IdealTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
supportingText = {},
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
)
}
if (state.isMonthly) {
item {
TextField(
value = state.email,
@@ -274,14 +301,21 @@ private fun IdealTransferDetailsContent(
}
}
),
supportingText = {},
isError = state.showEmailError(),
supportingText = {
if (state.showEmailError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__invalid_email_address))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
)
}
}
}
Buttons.LargeTonal(
enabled = state.canProceed(),
@@ -324,6 +358,7 @@ private fun IdealBankSelector(
Image(
painter = painterResource(id = uiValues?.icon ?: R.drawable.bank_transfer),
contentDescription = null,
colorFilter = if (uiValues?.icon == null) ColorFilter.tint(MaterialTheme.colorScheme.onSurface) else null,
modifier = Modifier
.padding(start = 16.dp, end = 12.dp)
.size(32.dp)

View File

@@ -6,12 +6,25 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankDetailsValidator
data class IdealTransferDetailsState(
val isMonthly: Boolean,
val idealBank: IdealBank? = null,
val name: String = "",
val email: String = ""
val nameFocusState: FocusState = FocusState.NOT_FOCUSED,
val email: String = "",
val emailFocusState: FocusState = FocusState.NOT_FOCUSED
) {
fun showNameError(): Boolean {
return nameFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validName(name)
}
fun showEmailError(): Boolean {
return emailFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validEmail(email)
}
fun asIDEALData(): StripeApi.IDEALData {
return StripeApi.IDEALData(
bank = idealBank!!.code,
@@ -21,6 +34,12 @@ data class IdealTransferDetailsState(
}
fun canProceed(): Boolean {
return idealBank != null && name.isNotBlank() && email.isNotBlank()
return idealBank != null && BankDetailsValidator.validName(name) && (!isMonthly || BankDetailsValidator.validEmail(email))
}
enum class FocusState {
NOT_FOCUSED,
FOCUSED,
LOST_FOCUS
}
}

View File

@@ -9,9 +9,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class IdealTransferDetailsViewModel : ViewModel() {
class IdealTransferDetailsViewModel(isMonthly: Boolean) : ViewModel() {
private val internalState = mutableStateOf(IdealTransferDetailsState())
private val internalState = mutableStateOf(IdealTransferDetailsState(isMonthly = isMonthly))
var state: State<IdealTransferDetailsState> = internalState
fun onNameChanged(name: String) {
@@ -26,9 +26,34 @@ class IdealTransferDetailsViewModel : ViewModel() {
)
}
fun onFocusChanged(field: Field, isFocused: Boolean) {
when (field) {
Field.NAME -> {
if (isFocused && internalState.value.nameFocusState == IdealTransferDetailsState.FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = IdealTransferDetailsState.FocusState.FOCUSED)
} else if (!isFocused && internalState.value.nameFocusState == IdealTransferDetailsState.FocusState.FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = IdealTransferDetailsState.FocusState.LOST_FOCUS)
}
}
Field.EMAIL -> {
if (isFocused && internalState.value.emailFocusState == IdealTransferDetailsState.FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = IdealTransferDetailsState.FocusState.FOCUSED)
} else if (!isFocused && internalState.value.emailFocusState == IdealTransferDetailsState.FocusState.FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = IdealTransferDetailsState.FocusState.LOST_FOCUS)
}
}
}
}
fun onBankSelected(idealBank: IdealBank) {
internalState.value = internalState.value.copy(
idealBank = idealBank
)
}
enum class Field {
NAME,
EMAIL
}
}

View File

@@ -5966,12 +5966,18 @@
<string name="BankTransferDetailsFragment__iban_country_code_is_not_supported">IBAN country code is not supported</string>
<!-- Error label for IBAN field when number is invalid -->
<string name="BankTransferDetailsFragment__invalid_iban">Invalid IBAN</string>
<!-- Error label for name field when name is not at least two characters long -->
<string name="BankTransferDetailsFragment__minimum_2_characters">Minimum 2 characters</string>
<!-- Error label for email field when email is not valid -->
<string name="BankTransferDetailsFragment__invalid_email_address">Invalid email address</string>
<!-- IdealTransferDetailsFragment -->
<!-- Title of the screen, displayed in the toolbar -->
<string name="IdealTransferDetailsFragment__ideal">iDEAL</string>
<!-- Subtitle of the screen, displayed below the toolbar. Placeholder is for \'learn more\' -->
<string name="IdealTransferDetailsFragment__enter_your_bank">Enter your bank, name and email. Stripe uses this email to send you updates about your donation. %1$s</string>
<!-- Subtitle of the screen, displayed below the toolbar. Placeholder is for \'learn more\' -->
<string name="IdealTransferDetailsFragment__enter_your_bank_details_one_time">Enter your bank details. Signal does not collect or store your personal information. %1$s</string>
<!-- Subtitle learn-more button displayed inline with the subtitle text -->
<string name="IdealTransferDetailsFragment__learn_more">Learn more</string>
<!-- Hint label for text entry box for name on bank account -->