diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcher.kt new file mode 100644 index 0000000000..27395f3feb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcher.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.text.Editable +import android.text.TextWatcher + +class CreditCardExpirationTextWatcher : TextWatcher { + + private var isBackspace = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + isBackspace = count == 0 + } + + override fun afterTextChanged(s: Editable) { + val text = s.toString() + val formattedText = when (text.length) { + 1 -> formatForSingleCharacter(text) + 2 -> formatForTwoCharacters(text) + else -> text + } + + val finalText = if (isBackspace && text.length < formattedText.length && formattedText.endsWith("/")) { + formattedText.dropLast(2) + } else { + formattedText + } + + if (finalText != text) { + s.replace(0, s.length, finalText) + } + } + + private fun formatForSingleCharacter(text: String): String { + val number = text.toIntOrNull() ?: return text + return if (number > 1) { + "0$number/" + } else { + text + } + } + + private fun formatForTwoCharacters(text: String): String { + val number = text.toIntOrNull() ?: return text + return if (number <= 12) { + "%02d/".format(number) + } else { + text + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt index 82b16f634f..218eb23fed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c import android.content.Context import android.os.Bundle import android.view.View +import android.view.inputmethod.EditorInfo import androidx.annotation.StringRes import androidx.core.os.bundleOf import androidx.core.widget.addTextChangedListener @@ -13,6 +14,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.LifecycleDisposable @@ -26,13 +28,21 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { private val lifecycleDisposable = LifecycleDisposable() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - - binding.title.text = getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat)) + binding.title.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) { + getString( + R.string.CreditCardFragment__donation_amount_s_per_month, + FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + ) + } else { + getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat)) + } binding.cardNumber.addTextChangedListener(afterTextChanged = { - viewModel.onNumberChanged(it?.toString() ?: "") + viewModel.onNumberChanged(it?.toString()?.filter { it != ' ' } ?: "") }) + binding.cardNumber.addTextChangedListener(CreditCardTextWatcher()) + binding.cardNumber.setOnFocusChangeListener { v, hasFocus -> viewModel.onNumberFocusChanged(hasFocus) } @@ -45,10 +55,21 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { viewModel.onCodeFocusChanged(hasFocus) } + binding.cardCvv.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + binding.continueButton.performClick() + true + } else { + false + } + } + binding.cardExpiry.addTextChangedListener(afterTextChanged = { viewModel.onExpirationChanged(it?.toString() ?: "") }) + binding.cardExpiry.addTextChangedListener(CreditCardExpirationTextWatcher()) + binding.cardExpiry.setOnFocusChangeListener { v, hasFocus -> viewModel.onExpirationFocusChanged(hasFocus) } @@ -112,7 +133,13 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { CreditCardExpirationValidator.Validity.INVALID_MONTH -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_month) CreditCardExpirationValidator.Validity.INVALID_YEAR -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_year) CreditCardExpirationValidator.Validity.POTENTIALLY_VALID -> NO_ERROR - CreditCardExpirationValidator.Validity.FULLY_VALID -> NO_ERROR + CreditCardExpirationValidator.Validity.FULLY_VALID -> { + if (binding.cardExpiry.isFocused) { + binding.cardCvv.requestFocus() + } + + NO_ERROR + } } binding.cardExpiryWrapper.error = errorState.resolveErrorText(requireContext()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcher.kt new file mode 100644 index 0000000000..395a994ee1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcher.kt @@ -0,0 +1,75 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.text.Editable +import android.text.TextWatcher + +/** + * Formats a credit card by type as the user modifies it. + */ +class CreditCardTextWatcher : TextWatcher { + + private var isBackspace: Boolean = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + isBackspace = count == 0 + } + + override fun afterTextChanged(s: Editable) { + val userInput = s.toString() + val normalizedNumber = userInput.filter { it != ' ' } + + val formattedNumber = when (CreditCardType.fromCardNumber(normalizedNumber)) { + CreditCardType.AMERICAN_EXPRESS -> applyAmexFormatting(normalizedNumber) + CreditCardType.UNIONPAY -> applyUnionPayFormatting(normalizedNumber) + CreditCardType.OTHER -> applyOtherFormatting(normalizedNumber) + } + + val backspaceHandled = if (isBackspace && formattedNumber.endsWith(' ') && formattedNumber.length > userInput.length) { + formattedNumber.dropLast(2) + } else { + formattedNumber + } + + if (userInput != backspaceHandled) { + s.replace(0, s.length, backspaceHandled) + } + } + + private fun applyAmexFormatting(normalizedNumber: String): String { + return applyGrouping(normalizedNumber, listOf(4, 6, 5)) + } + + private fun applyUnionPayFormatting(normalizedNumber: String): String { + return when { + normalizedNumber.length <= 13 -> applyGrouping(normalizedNumber, listOf(4, 4, 5)) + normalizedNumber.length <= 16 -> applyGrouping(normalizedNumber, listOf(4, 4, 4, 4)) + else -> applyGrouping(normalizedNumber, listOf(5, 5, 5, 4)) + } + } + + private fun applyOtherFormatting(normalizedNumber: String): String { + return if (normalizedNumber.length <= 16) { + applyGrouping(normalizedNumber, listOf(4, 4, 4, 4)) + } else { + applyGrouping(normalizedNumber, listOf(5, 5, 5, 4)) + } + } + + private fun applyGrouping(normalizedNumber: String, groups: List): String { + val maxCardLength = groups.sum() + + return groups.fold(0 to emptyList()) { acc, limit -> + val offset = acc.first + val section = normalizedNumber.drop(offset).take(limit) + val segment = if (limit == section.length && offset + limit != maxCardLength) { + "$section " + } else { + section + } + + (offset + limit) to acc.second + segment + }.second.filter { it.isNotEmpty() }.joinToString("") + } +} diff --git a/app/src/main/res/drawable/credit_card.xml b/app/src/main/res/drawable/credit_card.xml index 50b2dd2d2c..468be2f196 100644 --- a/app/src/main/res/drawable/credit_card.xml +++ b/app/src/main/res/drawable/credit_card.xml @@ -4,27 +4,27 @@ android:viewportWidth="32" android:viewportHeight="32"> diff --git a/app/src/main/res/layout/credit_card_fragment.xml b/app/src/main/res/layout/credit_card_fragment.xml index c118d17738..c2186bd0f2 100644 --- a/app/src/main/res/layout/credit_card_fragment.xml +++ b/app/src/main/res/layout/credit_card_fragment.xml @@ -48,7 +48,10 @@ android:layout_marginTop="36dp" android:hint="@string/CreditCardFragment__card_number" app:boxStrokeColor="@color/signal_colorPrimary" + app:boxStrokeErrorColor="@color/signal_colorError" app:errorEnabled="true" + app:errorIconTint="@color/signal_colorError" + app:errorTextColor="@color/signal_colorError" app:hintTextColor="@color/signal_colorPrimary" app:layout_constraintTop_toBottomOf="@id/description"> @@ -56,9 +59,11 @@ android:id="@+id/card_number" android:layout_width="match_parent" android:layout_height="wrap_content" + android:digits="0123456789 " + android:imeOptions="actionNext" android:inputType="number" - android:maxLength="19" - android:maxLines="1" /> + android:maxLines="1" + android:nextFocusDown="@id/card_expiry" /> @@ -71,7 +76,10 @@ android:hint="@string/CreditCardFragment__mm_yy" android:paddingEnd="18dp" app:boxStrokeColor="@color/signal_colorPrimary" + app:boxStrokeErrorColor="@color/signal_colorError" app:errorEnabled="true" + app:errorIconTint="@color/signal_colorError" + app:errorTextColor="@color/signal_colorError" app:hintTextColor="@color/signal_colorPrimary" app:layout_constraintEnd_toStartOf="@id/card_cvv_wrapper" app:layout_constraintStart_toStartOf="parent" @@ -81,6 +89,8 @@ android:id="@+id/card_expiry" android:layout_width="match_parent" android:layout_height="wrap_content" + android:digits="0123456789/" + android:imeOptions="actionNext" android:inputType="datetime|date" android:maxLength="5" android:maxLines="1" @@ -97,7 +107,10 @@ android:hint="@string/CreditCardFragment__cvv" android:paddingStart="18dp" app:boxStrokeColor="@color/signal_colorPrimary" + app:boxStrokeErrorColor="@color/signal_colorError" app:errorEnabled="true" + app:errorIconTint="@color/signal_colorError" + app:errorTextColor="@color/signal_colorError" app:hintTextColor="@color/signal_colorPrimary" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/card_expiry_wrapper" @@ -120,22 +133,11 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/dsl_settings_gutter" + android:layout_marginBottom="16dp" android:enabled="false" android:text="@string/CreditCardFragment__continue" - app:layout_constraintBottom_toTopOf="@id/notice" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - - \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_button_primary.xml b/app/src/main/res/layout/dsl_button_primary.xml index cbe7b3c738..2275a4585f 100644 --- a/app/src/main/res/layout/dsl_button_primary.xml +++ b/app/src/main/res/layout/dsl_button_primary.xml @@ -11,5 +11,6 @@ app:iconGravity="textStart" app:iconTint="@null" tools:icon="@drawable/credit_card" + app:iconSize="32dp" tools:text="Primary button" tools:viewBindingIgnore="true" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b72cb6daa6..c60f9975ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,8 +134,10 @@ Unblock - + Donation amount: %1$s + + Donation amount: %1$s/month Enter your card information below diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherBackspaceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherBackspaceTest.kt new file mode 100644 index 0000000000..277c120f89 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherBackspaceTest.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import android.text.Editable +import android.text.SpannableStringBuilder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardExpirationTextWatcherBackspaceTest( + private val beforeBackspace: String, + private val textWatcherOutput: String +) { + + private val testSubject = CreditCardExpirationTextWatcher() + + @Test + fun getTextWatcherOutput() { + val editable: Editable = SpannableStringBuilder(beforeBackspace.dropLast(1)) + testSubject.onTextChanged(null, 0, 0, 0) + testSubject.afterTextChanged(editable) + assertEquals(textWatcherOutput, editable.toString()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getTextWatcherOutput(..) = {0}, {1}") + fun data(): Iterable> = arrayListOf( + arrayOf("12/23", "12/2"), + arrayOf("12/2", "12/"), + arrayOf("12/", "1"), + arrayOf("1", "") + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherTest.kt new file mode 100644 index 0000000000..1a8bc37f82 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationTextWatcherTest.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import android.text.Editable +import android.text.SpannableStringBuilder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardExpirationTextWatcherTest( + private val userInput: String, + private val textWatcherOutput: String +) { + + private val testSubject = CreditCardExpirationTextWatcher() + + @Test + fun getTextWatcherOutput() { + val editable: Editable = SpannableStringBuilder(userInput) + testSubject.afterTextChanged(editable) + assertEquals(textWatcherOutput, editable.toString()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getTextWatcherOutput(..) = {0}, {1}") + fun data(): Iterable> = arrayListOf( + arrayOf("0", "0"), + arrayOf("1", "1"), + arrayOf("12", "12/"), + arrayOf("02", "02/"), + arrayOf("2", "02/"), + arrayOf("12/", "12/"), + arrayOf("12/1", "12/1"), + arrayOf("15", "15") + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherBackspaceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherBackspaceTest.kt new file mode 100644 index 0000000000..9beadd9d04 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherBackspaceTest.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import android.text.Editable +import android.text.SpannableStringBuilder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardTextWatcherBackspaceTest( + private val beforeBackspace: String, + private val textWatcherOutput: String +) { + + private val testSubject = CreditCardTextWatcher() + + @Test + fun getTextWatcherOutput() { + val editable: Editable = SpannableStringBuilder(beforeBackspace.dropLast(1)) + testSubject.onTextChanged(null, 0, 0, 0) + testSubject.afterTextChanged(editable) + assertEquals(textWatcherOutput, editable.toString()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getTextWatcherOutput(..) = {0}, {1}") + fun data(): Iterable> = arrayListOf( + arrayOf("1234 ", "123"), + arrayOf("1234 5", "1234 ") + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherTest.kt new file mode 100644 index 0000000000..fc93adb4a5 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTextWatcherTest.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import android.text.Editable +import android.text.SpannableStringBuilder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardTextWatcherTest( + private val userInput: String, + private val textWatcherOutput: String +) { + + private val testSubject = CreditCardTextWatcher() + + @Test + fun getTextWatcherOutput() { + val editable: Editable = SpannableStringBuilder(userInput) + testSubject.afterTextChanged(editable) + assertEquals(textWatcherOutput, editable.toString()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getTextWatcherOutput(..) = {0}, {1}") + fun data(): Iterable> = arrayListOf( + // AMEX + arrayOf("340", "340"), + arrayOf("3400", "3400 "), + arrayOf("34000", "3400 0"), + arrayOf("3400000000", "3400 000000 "), + arrayOf("34000000000", "3400 000000 0"), + arrayOf("340000000000000", "3400 000000 00000"), + // UNIONPAY + arrayOf("620", "620"), + arrayOf("6200", "6200 "), + arrayOf("62000", "6200 0"), + arrayOf("6200000000", "6200 0000 00"), + arrayOf("6200000000000", "6200 0000 00000"), + arrayOf("620000000000000", "6200 0000 0000 000"), + arrayOf("6200000000000000", "6200 0000 0000 0000"), + arrayOf("62000000000000000", "62000 00000 00000 00"), + // OTHER + arrayOf("550", "550"), + arrayOf("5500", "5500 "), + arrayOf("55000", "5500 0"), + arrayOf("5500000000", "5500 0000 00"), + arrayOf("55000000000", "5500 0000 000"), + arrayOf("550000000000000", "5500 0000 0000 000"), + arrayOf("5500000000000000", "5500 0000 0000 0000"), + arrayOf("55000000000000000", "55000 00000 00000 00"), + ) + } +}