diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankDetailsValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankDetailsValidator.kt new file mode 100644 index 0000000000..628c7ac142 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankDetailsValidator.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index c711661940..c4fe9952fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -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) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt index 4bc38fbe31..9332ad217e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt @@ -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 + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt index c0153e911e..604b80699d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt @@ -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,10 +31,30 @@ class BankTransferDetailsViewModel : ViewModel() { ) } - fun onIBANFocusChanged(isFocused: Boolean) { - internalState.value = internalState.value.copy( - ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused) - ) + 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) { @@ -48,4 +69,10 @@ class BankTransferDetailsViewModel : ViewModel() { email = email ) } + + enum class Field { + IBAN, + NAME, + EMAIL + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt index 246722893c..5819a51e93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -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,38 +268,52 @@ 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) } ) } - item { - TextField( - value = state.email, - onValueChange = onEmailChanged, - label = { - Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email)) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - if (state.canProceed()) { - onDonateClick() + if (state.isMonthly) { + item { + TextField( + value = state.email, + onValueChange = onEmailChanged, + label = { + Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (state.canProceed()) { + onDonateClick() + } } - } - ), - supportingText = {}, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .defaultMinSize(minHeight = 78.dp) - ) + ), + 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) } + ) + } } } @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt index e56e4c4531..627b6bfc30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt index 96c27cd477..18a0592855 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt @@ -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 = 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 + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4de6262225..4cd330ec3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5966,12 +5966,18 @@ IBAN country code is not supported Invalid IBAN + + Minimum 2 characters + + Invalid email address iDEAL Enter your bank, name and email. Stripe uses this email to send you updates about your donation. %1$s + + Enter your bank details. Signal does not collect or store your personal information. %1$s Learn more