// Copyright © 2022 Olo Inc. All rights reserved. // This software is made available under the Olo Pay SDK License (See LICENSE.md file) package com.olopaysdkreactnative.paymentcarddetailsview import android.annotation.SuppressLint import android.graphics.Typeface import android.os.Build import android.util.AttributeSet import android.util.TypedValue import androidx.constraintlayout.widget.ConstraintLayout import com.facebook.react.bridge.Arguments.createMap import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext import com.olo.olopay.api.OloPayAPI import com.olo.olopay.controls.PaymentCardDetailsSingleLineView import com.olo.olopay.controls.callbacks.CardInputListener import com.olo.olopay.data.CardField import com.olo.olopay.data.ICardFieldState import com.olo.olopay.exceptions.OloPayException import com.olopaysdkreactnative.R import com.olopaysdkreactnative.data.* import com.olopaysdkreactnative.events.CardDetailsChangedEvent import com.olopaysdkreactnative.events.FocusClearedEvent import com.olopaysdkreactnative.events.FocusFieldEvent import com.olopaysdkreactnative.events.FocusReceivedEvent import com.olopaysdkreactnative.events.PaymentMethodResultEvent import com.olopaysdkreactnative.extensions.* @SuppressLint("ViewConstructor") class PaymentCardDetailsView @JvmOverloads constructor( context: ThemedReactContext, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ): ConstraintLayout(context, attrs, defStyleAttr), CardInputListener, CoroutineScope { private var job = SupervisorJob() override val coroutineContext: CoroutineContext get() = Dispatchers.Main + job override fun onAttachedToWindow() { super.onAttachedToWindow() // Recreate job if it was cancelled (e.g., view was detached and reattached) if (job.isCancelled) { job = SupervisorJob() } } private var _cardDetails: PaymentCardDetailsSingleLineView private var _focusedField: CardField? = null private val reactContext: ThemedReactContext get() = context as ThemedReactContext private val surfaceId get() = UIManagerHelper.getSurfaceId(reactContext) private var _customErrorMessages: CustomErrorMessages? = null init { inflate(context, R.layout.paymentcarddetailsview, this) _cardDetails = findViewById(R.id.single_line_card) _cardDetails.cardInputListener = this } override fun setEnabled(enabled: Boolean) { _cardDetails.isEnabled = enabled } fun setPostalCodeEnabled(enabled: Boolean) { _cardDetails.postalCodeEnabled = enabled } fun setCustomErrorMessages(customErrorMessages: ReadableMap) { _customErrorMessages = CustomErrorMessages(customErrorMessages) } fun requestFocusFromJS(field:CardField, showKeyboard: Boolean) { _cardDetails.requestFocus(field, showKeyboard) } fun clearFocusFromJS() { _cardDetails.dismissKeyboard() } fun clearCardDetails() { _cardDetails.clearFields() } fun setPlaceholders(placeholders: ReadableMap) { val numberPlaceholder = placeholders.getString(DataKeys.NumberKey, "") val expirationPlaceholder = placeholders.getString(DataKeys.ExpirationKey, "") val cvvPlaceholder = placeholders.getString(DataKeys.CvvKey, "") val postalCodePlaceholder = placeholders.getString(DataKeys.PostalCodeKey, "") if (numberPlaceholder.isNotEmpty()) _cardDetails.setHintText(CardField.CardNumber, numberPlaceholder) if (cvvPlaceholder.isNotEmpty()) _cardDetails.setHintText(CardField.Cvv ,cvvPlaceholder) if (expirationPlaceholder.isNotEmpty()) _cardDetails.setHintText(CardField.Expiration, expirationPlaceholder) if (postalCodePlaceholder.isNotEmpty()) _cardDetails.setHintText(CardField.PostalCode, postalCodePlaceholder) } fun setCardStyles(cardStyles: ReadableMap) { val backgroundColor = cardStyles.getNullableString(DataKeys.BackgroundColorKey) val borderColor = cardStyles.getNullableString(DataKeys.BorderColorKey) val cursorColor = cardStyles.getString(DataKeys.CursorColorKey, "") val errorTextColor = cardStyles.getString(DataKeys.ErrorTextColorKey, "") val fontFamily = cardStyles.getNullableString(DataKeys.FontFamilyKey) val fontWeight = cardStyles.getNullableString(DataKeys.FontWeightKey) val hintTextColor = cardStyles.getString(DataKeys.PlaceholderColorKey, "") val italic = cardStyles.getBoolean(DataKeys.ItalicKey, false) val textSize = cardStyles.getNullableInt(DataKeys.FontSizeKey) val textColor = cardStyles.getString(DataKeys.TextColorKey, "") val displayMetrics = _cardDetails.context.resources.displayMetrics val borderWidth = cardStyles.getNullableInt(DataKeys.BorderWidthKey)?.let { TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() } val cornerRadius = cardStyles.getNullableInt(DataKeys.CornerRadiusKey)?.let { TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() } val textPaddingLeft = cardStyles.getNullableInt(DataKeys.TextPaddingLeftKey)?.let { TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() } val textPaddingRight = cardStyles.getNullableInt(DataKeys.TextPaddingRightKey)?.let { TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() } if(Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo){ _cardDetails.setTextColor(textColor) _cardDetails.setErrorTextColor(errorTextColor) _cardDetails.setHintTextColor(hintTextColor) } if(textSize != null) _cardDetails.setTextSize(textSize.toFloat()) if(cursorColor.isNotEmpty() && Build.VERSION.SDK_INT >= GlobalConstants.ApiQuinceTart) _cardDetails.setCursorColor(cursorColor) if(!fontFamily.isNullOrEmpty() || !fontWeight.isNullOrEmpty() || !cardStyles.hasKey(DataKeys.ItalicKey)) { val fontWeightValue = FontWeight.convertFrom(fontWeight).value val fontStyle = if(italic){ if(fontWeightValue >= FontWeight.Bold.value) Typeface.BOLD_ITALIC else Typeface.ITALIC } else if (fontWeightValue >= FontWeight.Bold.value) { Typeface.BOLD } else { Typeface.NORMAL } var typeface = if(!fontFamily.isNullOrEmpty()) { Typeface.create(fontFamily, fontStyle) } else { Typeface.create(Typeface.DEFAULT, fontStyle) } if(Build.VERSION.SDK_INT >= GlobalConstants.ApiPie) { typeface = Typeface.create(typeface, fontWeightValue, italic) } _cardDetails.setFont(typeface) } _cardDetails.setCardPadding( startPx = textPaddingLeft, topPx = null, endPx = textPaddingRight, bottomPx = null) if(Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo) { _cardDetails.setCardBackgroundStyle( backgroundColorHex = backgroundColor, borderColorHex = borderColor, borderWidthPx = borderWidth?.toFloat(), borderRadiusPx = cornerRadius?.toFloat()) } } fun createPaymentMethod() = launch { val params = _cardDetails.paymentMethodParams val eventData = createMap() if (params == null) { val errorMessage = getErrorMessage(false).ifEmpty { "Unable to get card params: Card is not valid" } val errorData = WritableNativeMap() errorData.putString(DataKeys.MessageKey, errorMessage) errorData.putString(DataKeys.CodeKey, getErrorCode()) eventData.putMap(DataKeys.ErrorKey, errorData) PaymentMethodResultEvent(surfaceId, id, eventData).emit(reactContext) return@launch } try { eventData.putMap( DataKeys.PaymentMethodKey, OloPayAPI().createPaymentMethod(context, params).toMap() ) } catch (e: OloPayException) { eventData.putMap(DataKeys.ErrorKey, e.toResultErrorMap()) } PaymentMethodResultEvent(surfaceId, id, eventData).emit(reactContext) } override fun onInputChanged(isValid: Boolean, fieldStates: Map) { emitCardDetailsChangedEvent() } override fun onFocusChange(field: CardField?, fieldStates: Map) { emitCardDetailsChangedEvent() if (field == null) { _focusedField = null FocusClearedEvent(surfaceId, id).emit(reactContext) } else { if (_focusedField == null) { FocusReceivedEvent(surfaceId, id).emit(reactContext) } _focusedField = field FocusFieldEvent(surfaceId, id, field).emit(reactContext) } } private fun emitCardDetailsChangedEvent() { val editedFieldsError = getErrorMessage(true) val allFieldsError = getErrorMessage(false) val event = CardDetailsChangedEvent(surfaceId, id, _cardDetails.fieldStates, _cardDetails.isValid, editedFieldsError, allFieldsError, _cardDetails.cardBrand) event.emit(reactContext) } private fun getErrorMessage(ignoreUneditedFields: Boolean): String { if(_cardDetails.isValid || !_cardDetails.hasErrorMessage(ignoreUneditedFields)) { return "" } val defaultErrorMessage = _cardDetails.getErrorMessage(ignoreUneditedFields) return _customErrorMessages?.getCustomErrorMessage(ignoreUneditedFields, _cardDetails.fieldStates, _cardDetails.cardBrand) ?: defaultErrorMessage } // This is needed to provide feature parity between iOS and Android. The Stripe iOS SDK is set // up in such a way that correct invalid field codes get reported back when trying to create // a payment method with invalid card details because an error is thrown with that information. // On Android, we just get a null value... private fun getErrorCode(): String { val fieldStates = _cardDetails.fieldStates val invalidFields = fieldStates.filter{ !it.value.isValid }.map{ it.key } if (invalidFields.contains(CardField.CardNumber)) return ErrorCodes.InvalidNumber if (invalidFields.contains(CardField.Expiration)) return ErrorCodes.InvalidExpiration if (invalidFields.contains(CardField.Cvv)) return ErrorCodes.InvalidCvv if (invalidFields.contains(CardField.PostalCode)) return ErrorCodes.InvalidPostalCode return ErrorCodes.GeneralError } override fun onDetachedFromWindow() { super.onDetachedFromWindow() job.cancel() // Cancel all coroutines when view is detached } // Simplified custom layout approach that works with both Paper and Fabric // Based on PaymentCardDetailsForm's implementation override fun requestLayout() { super.requestLayout() post(mLayoutRunnable) } private val mLayoutRunnable = Runnable { measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) layout(left, top, right, bottom) } }