// 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 android.view.Choreographer import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper 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.extensions.* @SuppressLint("ViewConstructor") class PaymentCardDetailsView @JvmOverloads constructor( context: ThemedReactContext, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ): ConstraintLayout(context, attrs, defStyleAttr), CardInputListener { 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()) } } suspend fun createPaymentMethod(promise: Promise) { val params = _cardDetails.paymentMethodParams if (params == null) { val errorMessage = getErrorMessage(false).ifEmpty { "Unable to get card params: Card is not valid" } promise.reject(getErrorCode(), errorMessage) return } try { val payment = OloPayAPI().createPaymentMethod(context, params) promise.resolve(payment.toMap()) } catch (e: OloPayException) { promise.rejectException(e) } } 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 requestLayout() { super.requestLayout() post { manuallyLayoutView(this) manuallyLayoutView(_cardDetails) for (child in _cardDetails.children) { manuallyLayoutView(child) } } } private fun manuallyLayoutView(view: View) { view.measure( MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) ) view.layout(view.left, view.top, view.measuredWidth, view.measuredHeight) } }