Donations credit card formatting.

main
Alex Hart 2022-11-03 17:22:03 -03:00 zatwierdzone przez Cody Henthorne
rodzic 16cbc971a5
commit b8e16353ab
11 zmienionych plików z 363 dodań i 27 usunięć

Wyświetl plik

@ -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
}
}
}

Wyświetl plik

@ -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())

Wyświetl plik

@ -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<Int>): String {
val maxCardLength = groups.sum()
return groups.fold(0 to emptyList<String>()) { 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("")
}
}

Wyświetl plik

@ -4,27 +4,27 @@
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M3.368,5L28.632,5A3.368,3.368 0,0 1,32 8.368L32,23.632A3.368,3.368 0,0 1,28.632 27L3.368,27A3.368,3.368 0,0 1,0 23.632L0,8.368A3.368,3.368 0,0 1,3.368 5z"
android:pathData="M4.158,6L27.842,6A3.158,3.158 0,0 1,31 9.158L31,22.842A3.158,3.158 0,0 1,27.842 26L4.158,26A3.158,3.158 0,0 1,1 22.842L1,9.158A3.158,3.158 0,0 1,4.158 6z"
android:fillColor="#FBFCFE"/>
<path
android:pathData="M3.368,5L28.632,5A3.368,3.368 0,0 1,32 8.368L32,23.632A3.368,3.368 0,0 1,28.632 27L3.368,27A3.368,3.368 0,0 1,0 23.632L0,8.368A3.368,3.368 0,0 1,3.368 5z"
android:pathData="M4.158,6L27.842,6A3.158,3.158 0,0 1,31 9.158L31,22.842A3.158,3.158 0,0 1,27.842 26L4.158,26A3.158,3.158 0,0 1,1 22.842L1,9.158A3.158,3.158 0,0 1,4.158 6z"
android:fillColor="#50679F"
android:fillAlpha="0.11"/>
<path
android:pathData="M22.737,8.259L27.79,8.259A0.842,0.842 0,0 1,28.632 9.101L28.632,13.936A0.842,0.842 0,0 1,27.79 14.778L22.737,14.778A0.842,0.842 0,0 1,21.895 13.936L21.895,9.101A0.842,0.842 0,0 1,22.737 8.259z"
android:pathData="M22.316,8.963L27.053,8.963A0.789,0.789 0,0 1,27.842 9.752L27.842,14.099A0.789,0.789 0,0 1,27.053 14.889L22.316,14.889A0.789,0.789 0,0 1,21.527 14.099L21.527,9.752A0.789,0.789 0,0 1,22.316 8.963z"
android:strokeAlpha="0.4"
android:fillColor="#586071"
android:fillAlpha="0.4"/>
<path
android:pathData="M4.183,20.482L7.606,20.482A0.815,0.815 0,0 1,8.421 21.296L8.421,21.296A0.815,0.815 0,0 1,7.606 22.111L4.183,22.111A0.815,0.815 0,0 1,3.369 21.296L3.369,21.296A0.815,0.815 0,0 1,4.183 20.482z"
android:pathData="M4.899,20.074L8.154,20.074A0.741,0.741 0,0 1,8.895 20.815L8.895,20.815A0.741,0.741 0,0 1,8.154 21.556L4.899,21.556A0.741,0.741 0,0 1,4.158 20.815L4.158,20.815A0.741,0.741 0,0 1,4.899 20.074z"
android:fillColor="#A1ACC4"/>
<path
android:pathData="M10.92,20.482L14.343,20.482A0.815,0.815 0,0 1,15.158 21.296L15.158,21.296A0.815,0.815 0,0 1,14.343 22.111L10.92,22.111A0.815,0.815 0,0 1,10.105 21.296L10.105,21.296A0.815,0.815 0,0 1,10.92 20.482z"
android:pathData="M11.214,20.074L14.47,20.074A0.741,0.741 0,0 1,15.21 20.815L15.21,20.815A0.741,0.741 0,0 1,14.47 21.556L11.214,21.556A0.741,0.741 0,0 1,10.474 20.815L10.474,20.815A0.741,0.741 0,0 1,11.214 20.074z"
android:fillColor="#A1ACC4"/>
<path
android:pathData="M17.657,20.482L21.08,20.482A0.815,0.815 0,0 1,21.894 21.296L21.894,21.296A0.815,0.815 0,0 1,21.08 22.111L17.657,22.111A0.815,0.815 0,0 1,16.842 21.296L16.842,21.296A0.815,0.815 0,0 1,17.657 20.482z"
android:pathData="M17.53,20.074L20.785,20.074A0.741,0.741 0,0 1,21.526 20.815L21.526,20.815A0.741,0.741 0,0 1,20.785 21.556L17.53,21.556A0.741,0.741 0,0 1,16.789 20.815L16.789,20.815A0.741,0.741 0,0 1,17.53 20.074z"
android:fillColor="#A1ACC4"/>
<path
android:pathData="M24.394,20.482L27.817,20.482A0.815,0.815 0,0 1,28.632 21.296L28.632,21.296A0.815,0.815 0,0 1,27.817 22.111L24.394,22.111A0.815,0.815 0,0 1,23.579 21.296L23.579,21.296A0.815,0.815 0,0 1,24.394 20.482z"
android:pathData="M23.846,20.074L27.102,20.074A0.741,0.741 0,0 1,27.842 20.815L27.842,20.815A0.741,0.741 0,0 1,27.102 21.556L23.846,21.556A0.741,0.741 0,0 1,23.105 20.815L23.105,20.815A0.741,0.741 0,0 1,23.846 20.074z"
android:fillColor="#A1ACC4"/>
</vector>

Wyświetl plik

@ -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" />
</com.google.android.material.textfield.TextInputLayout>
@ -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" />
<TextView
android:id="@+id/notice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingBottom="20dp"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="Signal will never sell or trade your information to anyone. More of an explanation if needed." />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -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" />

Wyświetl plik

@ -134,8 +134,10 @@
<string name="BlockedUsersActivity__unblock">Unblock</string>
<!-- CreditCardFragment -->
<!-- Title of fragment detailing the donation amount, displayed above the credit card text fields -->
<!-- Title of fragment detailing the donation amount for one-time donation, displayed above the credit card text fields -->
<string name="CreditCardFragment__donation_amount_s">Donation amount: %1$s</string>
<!-- Title of fragment detailing the donation amount for monthly donation, displayed above the credit card text fields -->
<string name="CreditCardFragment__donation_amount_s_per_month">Donation amount: %1$s/month</string>
<!-- Explanation of how to fill in the form, displayed above the credit card text fields -->
<string name="CreditCardFragment__enter_your_card_information_below">Enter your card information below</string>
<!-- Displayed as a hint in the card number text field -->

Wyświetl plik

@ -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<Array<Any>> = arrayListOf(
arrayOf("12/23", "12/2"),
arrayOf("12/2", "12/"),
arrayOf("12/", "1"),
arrayOf("1", "")
)
}
}

Wyświetl plik

@ -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<Array<Any>> = 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")
)
}
}

Wyświetl plik

@ -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<Array<Any>> = arrayListOf(
arrayOf("1234 ", "123"),
arrayOf("1234 5", "1234 ")
)
}
}

Wyświetl plik

@ -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<Array<Any>> = 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"),
)
}
}