Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.NavHostFragment
import com.braintreepayments.api.card.Card
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsResult
import com.braintreepayments.api.uicomponents.compose.CardFields
import com.braintreepayments.api.uicomponents.compose.rememberCardFieldsController

Expand All @@ -23,10 +35,48 @@ class ComposeCardFieldsFragment : BaseFragment() {
super.onCreateView(inflater, container, savedInstanceState)
return ComposeView(requireContext()).apply {
setContent {
val cardFieldsController = rememberCardFieldsController()
val cardFieldsController = rememberCardFieldsController(
authorization = authStringArg,
// optional customer data
request = Card(
cardholderName = "John Doe",
postalCode = "12345"
)
)
val isFormValid by cardFieldsController.isFormValid.collectAsState()

Column(modifier = Modifier.padding(16.dp)) {
CardFields(controller = cardFieldsController)
Button(
modifier = Modifier
.height(68.dp)
.fillMaxWidth()
.padding(top = 16.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Black,
contentColor = Color.White,
disabledContainerColor = Color.Gray,
disabledContentColor = Color.White
),
enabled = isFormValid,
onClick = {
cardFieldsController.submit { result ->
when (result) {
is CardFieldsResult.Success -> {
onPaymentMethodNonceCreated(result.nonce)
val action = ComposeCardFieldsFragmentDirections
.actionComposeCardFieldsFragmentToDisplayNonceFragment(result.nonce)
NavHostFragment.findNavController(this@ComposeCardFieldsFragment)
.navigate(action)
}
is CardFieldsResult.Failure -> handleError(result.error)
}
}
}
) {
Text(getString(R.string.card_fields_pay))
}
}
}
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -51,7 +50,7 @@ fun CardFields(controller: CardFieldsController, modifier: Modifier = Modifier)

Column(
modifier = modifier
.fillMaxSize()
.fillMaxWidth()
.clickable(
Comment thread
saralvasquez marked this conversation as resolved.
indication = null,
interactionSource = remember { MutableInteractionSource() }
Expand Down
Comment thread
mzlangreder marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,25 +1,87 @@
package com.braintreepayments.api.uicomponents.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.braintreepayments.api.card.Card
import com.braintreepayments.api.card.CardClient
import com.braintreepayments.api.card.CardResult
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsResult
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsResultCallback
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsViewModel
import kotlinx.coroutines.flow.StateFlow

class CardFieldsController internal constructor(
internal val viewModel: CardFieldsViewModel,
internal val cardNumber: MutableState<TextFieldValue>,
internal val expiration: MutableState<TextFieldValue>,
internal val cvv: MutableState<TextFieldValue>,
private val cardClient: CardClient,
private val request: Card = Card(),
) {
internal var cardNumber = mutableStateOf(viewModel.currentCardNumber.asTextFieldValue())
internal var expiration = mutableStateOf(viewModel.currentExpiration.asTextFieldValue())
internal var cvv = mutableStateOf(viewModel.currentCvv.asTextFieldValue())
val isFormValid: StateFlow<Boolean> = viewModel.isFormValid

/**
* Tokenizes the card details entered by the user, merged with any additional data provided via
* [request]. The result is delivered to [callback].
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are very helpful!

fun submit(callback: CardFieldsResultCallback) {
cardClient.tokenize(buildCard()) { cardResult ->
val result = when (cardResult) {
is CardResult.Success -> CardFieldsResult.Success(cardResult.nonce)
is CardResult.Failure -> CardFieldsResult.Failure(cardResult.error)
}
callback.onCardFieldsResult(result)
}
}
Comment thread
mzlangreder marked this conversation as resolved.

private fun buildCard(): Card {
val rawExpiration = expiration.value.text
return request.copy(
number = cardNumber.value.text,
expirationMonth = rawExpiration.take(2),
expirationYear = rawExpiration.drop(2),
cvv = cvv.value.text
)
}
}

private fun String.asTextFieldValue() = TextFieldValue(text = this, selection = TextRange(length))

@Composable
fun rememberCardFieldsController(): CardFieldsController {
fun rememberCardFieldsController(authorization: String, request: Card = Card()): CardFieldsController {
val context = LocalContext.current
val viewModel = viewModel<CardFieldsViewModel>()
return remember(viewModel) { CardFieldsController(viewModel) }
val cardNumber = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(viewModel.currentCardNumber.asTextFieldValue())
}
val expiration = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(viewModel.currentExpiration.asTextFieldValue())
}
val cvv = rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(viewModel.currentCvv.asTextFieldValue())
}

LaunchedEffect(viewModel) {
if (cardNumber.value.text != viewModel.currentCardNumber) {
viewModel.onCardNumberChanged(cardNumber.value.text)
}
if (expiration.value.text != viewModel.currentExpiration) {
viewModel.onExpiryChanged(expiration.value.text)
}
if (cvv.value.text != viewModel.currentCvv) {
viewModel.onCvvChanged(cvv.value.text)
}
}

return remember(viewModel, authorization, request) {
CardFieldsController(viewModel, cardNumber, expiration, cvv, CardClient(context, authorization), request)
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
package com.braintreepayments.api.uicomponents.compose

import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.braintreepayments.api.card.Card
import com.braintreepayments.api.card.CardClient
import com.braintreepayments.api.card.CardNonce
import com.braintreepayments.api.card.CardResult
import com.braintreepayments.api.card.CardTokenizeCallback
import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsResult
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsViewModel
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class CardFieldsControllerUnitTest {

private val cardClient: CardClient = mockk(relaxed = true)

private fun String.asTextFieldValue() = TextFieldValue(text = this, selection = TextRange(length))

private fun createCardFieldsController(
viewModel: CardFieldsViewModel = CardFieldsViewModel(),
request: Card = Card(),
) =
CardFieldsController(
viewModel,
mutableStateOf(viewModel.currentCardNumber.asTextFieldValue()),
mutableStateOf(viewModel.currentExpiration.asTextFieldValue()),
mutableStateOf(viewModel.currentCvv.asTextFieldValue()),
cardClient,
request
)

@Test
fun `cardNumber initializes empty with cursor at the start when the view model has no value`() {
val controller = CardFieldsController(CardFieldsViewModel())
val controller = createCardFieldsController()
assertEquals("", controller.cardNumber.value.text)
assertEquals(TextRange(0), controller.cardNumber.value.selection)
}
Expand All @@ -19,15 +52,15 @@ class CardFieldsControllerUnitTest {
val viewModel = CardFieldsViewModel()
viewModel.onCardNumberChanged("4111")

val controller = CardFieldsController(viewModel)
val controller = createCardFieldsController(viewModel)

assertEquals("4111", controller.cardNumber.value.text)
assertEquals(TextRange(4), controller.cardNumber.value.selection)
}

@Test
fun `expiration initializes empty with cursor at the start when the view model has no value`() {
val controller = CardFieldsController(CardFieldsViewModel())
val controller = createCardFieldsController()
assertEquals("", controller.expiration.value.text)
assertEquals(TextRange(0), controller.expiration.value.selection)
}
Expand All @@ -37,15 +70,15 @@ class CardFieldsControllerUnitTest {
val viewModel = CardFieldsViewModel()
viewModel.onExpiryChanged("1225")

val controller = CardFieldsController(viewModel)
val controller = createCardFieldsController(viewModel)

assertEquals("1225", controller.expiration.value.text)
assertEquals(TextRange(4), controller.expiration.value.selection)
}

@Test
fun `cvv initializes empty with cursor at the start when the view model has no value`() {
val controller = CardFieldsController(CardFieldsViewModel())
val controller = createCardFieldsController()
assertEquals("", controller.cvv.value.text)
assertEquals(TextRange(0), controller.cvv.value.selection)
}
Expand All @@ -55,9 +88,116 @@ class CardFieldsControllerUnitTest {
val viewModel = CardFieldsViewModel()
viewModel.onCvvChanged("123")

val controller = CardFieldsController(viewModel)
val controller = createCardFieldsController(viewModel)

assertEquals("123", controller.cvv.value.text)
assertEquals(TextRange(3), controller.cvv.value.selection)
}

// region isFormValid

@Test
fun `isFormValid reflects the view model's isFormValid flow`() {
val viewModel = CardFieldsViewModel()
val controller = createCardFieldsController(viewModel)

assertEquals(viewModel.isFormValid, controller.isFormValid)
}

// endregion

// region Tokenization

@Test
fun `submit maps a CardResult Success to a CardFieldsResult Success`() {
val nonce = mockk<CardNonce>()
val successResult = mockk<CardResult.Success>()
every { successResult.nonce } returns nonce
every { cardClient.tokenize(any(), any()) } answers {
secondArg<CardTokenizeCallback>().onCardResult(successResult)
}
val controller = createCardFieldsController()
var result: CardFieldsResult? = null

controller.submit { result = it }

assertTrue(result is CardFieldsResult.Success)
assertEquals(nonce, (result as CardFieldsResult.Success).nonce)
}

@Test
fun `submit maps a CardResult Failure to a CardFieldsResult Failure`() {
val error = BraintreeException("tokenization failed")
val failureResult = mockk<CardResult.Failure>()
every { failureResult.error } returns error
every { cardClient.tokenize(any(), any()) } answers {
secondArg<CardTokenizeCallback>().onCardResult(failureResult)
}
val controller = createCardFieldsController()
var result: CardFieldsResult? = null

controller.submit { result = it }

assertTrue(result is CardFieldsResult.Failure)
assertEquals(error, (result as CardFieldsResult.Failure).error)
}

@Test
fun `submit tokenizes the user-entered card fields`() {
val cardSlot = slot<Card>()
every { cardClient.tokenize(capture(cardSlot), any()) } just Runs
val controller = createCardFieldsController()
controller.cardNumber.value = controller.cardNumber.value.copy(text = "4111111111111111")
controller.expiration.value = controller.expiration.value.copy(text = "1226")
controller.cvv.value = controller.cvv.value.copy(text = "123")

controller.submit { }

val captured = cardSlot.captured
assertEquals("4111111111111111", captured.number)
assertEquals("12", captured.expirationMonth)
assertEquals("26", captured.expirationYear)
assertEquals("123", captured.cvv)
}

@Test
fun `submit merges UI fields over the payment request, with all fields correctly preserved`() {
val cardSlot = slot<Card>()
every { cardClient.tokenize(capture(cardSlot), any()) } just Runs
val controller = createCardFieldsController(
request = Card(cardholderName = "Jane Doe", postalCode = "94107", number = "0000")
)
controller.cardNumber.value = controller.cardNumber.value.copy(text = "4111111111111111")
controller.expiration.value = controller.expiration.value.copy(text = "1226")
controller.cvv.value = controller.cvv.value.copy(text = "123")

controller.submit { }

val captured = cardSlot.captured
// UI-entered number overrides the merchant-supplied "0000".
assertEquals("4111111111111111", captured.number)
// Merchant-only metadata is preserved by copy().
assertEquals("Jane Doe", captured.cardholderName)
assertEquals("94107", captured.postalCode)
}

@Test
fun `submit without a payment request still tokenizes the UI fields`() {
val cardSlot = slot<Card>()
every { cardClient.tokenize(capture(cardSlot), any()) } just Runs
val controller = createCardFieldsController()
controller.cardNumber.value = controller.cardNumber.value.copy(text = "4111111111111111")
controller.expiration.value = controller.expiration.value.copy(text = "1226")
controller.cvv.value = controller.cvv.value.copy(text = "123")

controller.submit { }

val captured = cardSlot.captured
assertEquals("4111111111111111", captured.number)
// No payment request was set, so metadata fields fall back to the empty Card() defaults.
assertNull(captured.cardholderName)
assertNull(captured.postalCode)
}

// endregion
}
Loading