diff --git a/packages/stripe/lib/flutter_stripe.dart b/packages/stripe/lib/flutter_stripe.dart index 98acdb688..dfefec36b 100644 --- a/packages/stripe/lib/flutter_stripe.dart +++ b/packages/stripe/lib/flutter_stripe.dart @@ -7,5 +7,7 @@ export 'src/widgets/adress_sheet.dart'; export 'src/widgets/aubecs_debit_form.dart'; export 'src/widgets/card_field.dart'; export 'src/widgets/card_form_field.dart'; +export 'src/widgets/embedded_payment_element.dart'; +export 'src/widgets/embedded_payment_element_controller.dart'; // export 'src/widgets/google_pay_button.dart'; export 'src/widgets/platform_pay_button.dart'; diff --git a/packages/stripe/lib/src/model/apple_pay_button.dart b/packages/stripe/lib/src/model/apple_pay_button.dart index 4c5837e96..194938be1 100644 --- a/packages/stripe/lib/src/model/apple_pay_button.dart +++ b/packages/stripe/lib/src/model/apple_pay_button.dart @@ -20,9 +20,4 @@ enum ApplePayButtonType { } /// Predefined styles for the Apple pay button. -enum ApplePayButtonStyle { - white, - whiteOutline, - black, - automatic, -} +enum ApplePayButtonStyle { white, whiteOutline, black, automatic } diff --git a/packages/stripe/lib/src/stripe.dart b/packages/stripe/lib/src/stripe.dart index c6ca777cc..69dc388d6 100644 --- a/packages/stripe/lib/src/stripe.dart +++ b/packages/stripe/lib/src/stripe.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; import 'package:stripe_platform_interface/stripe_platform_interface.dart'; /// [Stripe] is the facade of the library and exposes the operations that can be @@ -102,13 +101,13 @@ class Stripe { /// [publishableKey], [merchantIdentifier], [stripeAccountId], /// [threeDSecureParams], [urlScheme], [setReturnUrlSchemeOnAndroid] Future applySettings() => _initialise( - publishableKey: publishableKey, - merchantIdentifier: merchantIdentifier, - stripeAccountId: stripeAccountId, - threeDSecureParams: threeDSecureParams, - urlScheme: urlScheme, - setReturnUrlSchemeOnAndroid: setReturnUrlSchemeOnAndroid, - ); + publishableKey: publishableKey, + merchantIdentifier: merchantIdentifier, + stripeAccountId: stripeAccountId, + threeDSecureParams: threeDSecureParams, + urlScheme: urlScheme, + setReturnUrlSchemeOnAndroid: setReturnUrlSchemeOnAndroid, + ); /// Exposes a [ValueListenable] whether or not GooglePay (on Android) or Apple Pay (on iOS) /// is supported for this device. @@ -133,8 +132,9 @@ class Stripe { }) async { await _awaitForSettings(); final isSupported = await _platform.isPlatformPaySupported( - params: googlePay, - paymentRequestOptions: webPaymentRequestCreateOptions); + params: googlePay, + paymentRequestOptions: webPaymentRequestCreateOptions, + ); _isPlatformPaySupported ??= ValueNotifier(false); _isPlatformPaySupported?.value = isSupported; @@ -276,8 +276,10 @@ class Stripe { }) async { await _awaitForSettings(); try { - final paymentMethod = - await _platform.createPaymentMethod(params, options); + final paymentMethod = await _platform.createPaymentMethod( + params, + options, + ); return paymentMethod; } on StripeError catch (error) { throw StripeError(message: error.message, code: error.message); @@ -370,12 +372,16 @@ class Stripe { /// several seconds and it is important to not resubmit the form. /// /// Throws a [StripeException] when confirming the handle card action fails. - Future handleNextAction(String paymentIntentClientSecret, - {String? returnURL}) async { + Future handleNextAction( + String paymentIntentClientSecret, { + String? returnURL, + }) async { await _awaitForSettings(); try { - final paymentIntent = await _platform - .handleNextAction(paymentIntentClientSecret, returnURL: returnURL); + final paymentIntent = await _platform.handleNextAction( + paymentIntentClientSecret, + returnURL: returnURL, + ); return paymentIntent; } on StripeError { //throw StripeError(error.code, error.message); @@ -389,13 +395,15 @@ class Stripe { /// /// Throws a [StripeException] when confirming the handle card action fails. Future handleNextActionForSetupIntent( - String setupIntentClientSecret, - {String? returnURL}) async { + String setupIntentClientSecret, { + String? returnURL, + }) async { await _awaitForSettings(); try { final paymentIntent = await _platform.handleNextActionForSetupIntent( - setupIntentClientSecret, - returnURL: returnURL); + setupIntentClientSecret, + returnURL: returnURL, + ); return paymentIntent; } on StripeError { rethrow; @@ -416,7 +424,10 @@ class Stripe { await _awaitForSettings(); try { final setupIntent = await _platform.confirmSetupIntent( - paymentIntentClientSecret, params, options); + paymentIntentClientSecret, + params, + options, + ); return setupIntent; } on StripeException { rethrow; @@ -428,14 +439,10 @@ class Stripe { /// Returns a single-use token. /// /// Throws [StripeError] in case creating the token fails. - Future createTokenForCVCUpdate( - String cvc, - ) async { + Future createTokenForCVCUpdate(String cvc) async { await _awaitForSettings(); try { - final tokenId = await _platform.createTokenForCVCUpdate( - cvc, - ); + final tokenId = await _platform.createTokenForCVCUpdate(cvc); return tokenId; } on StripeError { //throw StripeError(error.code, error.message); @@ -451,9 +458,10 @@ class Stripe { required SetupPaymentSheetParameters paymentSheetParameters, }) async { assert( - !(paymentSheetParameters.applePay != null && - instance._merchantIdentifier == null), - 'merchantIdentifier must be specified if you are using Apple Pay. Please refer to this article to get a merchant identifier: https://support.stripe.com/questions/enable-apple-pay-on-your-stripe-account'); + !(paymentSheetParameters.applePay != null && + instance._merchantIdentifier == null), + 'merchantIdentifier must be specified if you are using Apple Pay. Please refer to this article to get a merchant identifier: https://support.stripe.com/questions/enable-apple-pay-on-your-stripe-account', + ); await _awaitForSettings(); return _platform.initPaymentSheet(paymentSheetParameters); } @@ -473,11 +481,18 @@ class Stripe { /// Method used to confirm to the user that the intent is created successfull /// or not successfull when using a defferred payment method. Future intentCreationCallback( - IntentCreationCallbackParams params) async { + IntentCreationCallbackParams params, + ) async { await _awaitForSettings(); return await _platform.intentCreationCallback(params); } + /// Registers a callback that the native embedded element invokes when it + /// needs the app to create an intent client secret. + void setConfirmHandler(ConfirmHandler? handler) { + _platform.setConfirmHandler(handler); + } + /// Call this method when the user logs out from your app. /// /// This will ensure that any persisted authentication state in the @@ -506,7 +521,8 @@ class Stripe { /// Inititialise google pay @Deprecated( - 'Use [confirmPlatformPaySetupIntent] or [confirmPlatformPayPaymentIntent] or [createPlatformPayPaymentMethod] instead.') + 'Use [confirmPlatformPaySetupIntent] or [confirmPlatformPayPaymentIntent] or [createPlatformPayPaymentMethod] instead.', + ) Future initGooglePay(GooglePayInitParams params) async { return await _platform.initGooglePay(params); } @@ -515,7 +531,8 @@ class Stripe { /// /// Throws a [StripeException] in case it is failing @Deprecated( - 'Use [confirmPlatformPaySetupIntent] or [confirmPlatformPayPaymentIntent].') + 'Use [confirmPlatformPaySetupIntent] or [confirmPlatformPayPaymentIntent].', + ) Future presentGooglePay(PresentGooglePayParams params) async { return await _platform.presentGooglePay(params); } @@ -525,7 +542,8 @@ class Stripe { /// Throws a [StripeException] in case it is failing @Deprecated('Use [createPlatformPayPaymentMethod instead.') Future createGooglePayPaymentMethod( - CreateGooglePayPaymentParams params) async { + CreateGooglePayPaymentParams params, + ) async { return await _platform.createGooglePayPaymentMethod(params); } @@ -566,11 +584,9 @@ class Stripe { /// iOS at the moment. Future verifyPaymentIntentWithMicrodeposits({ /// Whether the clientsecret is associated with setup or paymentintent - required bool isPaymentIntent, /// The clientSecret of the payment and setup intent - required String clientSecret, /// Parameters to verify the microdeposits. @@ -596,7 +612,8 @@ class Stripe { /// on this particular device. /// Throws [StripeException] in case creating the token fails. Future canAddCardToWallet( - CanAddCardToWalletParams params) async { + CanAddCardToWalletParams params, + ) async { return await _platform.canAddCardToWallet(params); } @@ -650,8 +667,9 @@ class Stripe { } /// Initializes the customer sheet with the provided [parameters]. - Future initCustomerSheet( - {required CustomerSheetInitParams customerSheetInitParams}) async { + Future initCustomerSheet({ + required CustomerSheetInitParams customerSheetInitParams, + }) async { await _awaitForSettings(); return _platform.initCustomerSheet(customerSheetInitParams); } @@ -666,7 +684,7 @@ class Stripe { /// Retrieve the customer sheet payment option selection. Future - retrieveCustomerSheetPaymentOptionSelection() async { + retrieveCustomerSheetPaymentOptionSelection() async { await _awaitForSettings(); return _platform.retrieveCustomerSheetPaymentOptionSelection(); @@ -711,13 +729,14 @@ class Stripe { } } - Future _initialise( - {required String publishableKey, - String? stripeAccountId, - ThreeDSecureConfigurationParams? threeDSecureParams, - String? merchantIdentifier, - String? urlScheme, - bool? setReturnUrlSchemeOnAndroid}) async { + Future _initialise({ + required String publishableKey, + String? stripeAccountId, + ThreeDSecureConfigurationParams? threeDSecureParams, + String? merchantIdentifier, + String? urlScheme, + bool? setReturnUrlSchemeOnAndroid, + }) async { _needsSettings = false; await _platform.initialise( publishableKey: publishableKey, diff --git a/packages/stripe/lib/src/widgets/adress_sheet.dart b/packages/stripe/lib/src/widgets/adress_sheet.dart index a30fff58a..04461c401 100644 --- a/packages/stripe/lib/src/widgets/adress_sheet.dart +++ b/packages/stripe/lib/src/widgets/adress_sheet.dart @@ -58,8 +58,9 @@ class _AddressSheetState extends State<_AddressSheet> { _methodChannel?.setMethodCallHandler((call) async { if (call.method == 'onSubmitAction') { try { - final tmp = - Map.from(call.arguments as Map)['result']; + final tmp = Map.from( + call.arguments as Map, + )['result']; final tmpAdress = Map.from(tmp['address'] as Map); widget.onSubmit( @@ -70,7 +71,9 @@ class _AddressSheetState extends State<_AddressSheet> { ), ); } catch (e) { - log('An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new'); + log( + 'An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new', + ); rethrow; } } else if (call.method == 'onErrorAction') { @@ -78,7 +81,8 @@ class _AddressSheetState extends State<_AddressSheet> { final foo = Map.from(tmp['error'] as Map); widget.onError( - StripeException(error: LocalizedErrorMessage.fromJson(foo))); + StripeException(error: LocalizedErrorMessage.fromJson(foo)), + ); } }); } @@ -99,21 +103,22 @@ class _AddressSheetState extends State<_AddressSheet> { return AndroidViewSurface( controller: controller as AndroidViewController, hitTestBehavior: PlatformViewHitTestBehavior.opaque, - gestureRecognizers: const >{}, + gestureRecognizers: + const >{}, ); }, onCreatePlatformView: (params) { onPlatformViewCreated(params.id); return PlatformViewsService.initExpensiveAndroidView( - id: params.id, - viewType: _viewType, - layoutDirection: TextDirection.ltr, - creationParams: widget.addressSheetParams.toJson(), - creationParamsCodec: const StandardMessageCodec(), - ) + id: params.id, + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParams: widget.addressSheetParams.toJson(), + creationParamsCodec: const StandardMessageCodec(), + ) ..addOnPlatformViewCreatedListener( - params.onPlatformViewCreated) + params.onPlatformViewCreated, + ) ..create(); }, viewType: _viewType, diff --git a/packages/stripe/lib/src/widgets/apple_pay_button.dart b/packages/stripe/lib/src/widgets/apple_pay_button.dart index 58535bf5c..fc3703e0d 100644 --- a/packages/stripe/lib/src/widgets/apple_pay_button.dart +++ b/packages/stripe/lib/src/widgets/apple_pay_button.dart @@ -26,11 +26,11 @@ class ApplePayButton extends StatelessWidget { this.onShippingMethodSelected, this.onCouponCodeEntered, this.onOrderTracking, - }) : assert(constraints == null || constraints.debugAssertIsValid()), - constraints = (width != null || height != null) - ? constraints?.tighten(width: width, height: height) ?? - BoxConstraints.tightFor(width: width, height: height) - : constraints; + }) : assert(constraints == null || constraints.debugAssertIsValid()), + constraints = (width != null || height != null) + ? constraints?.tighten(width: width, height: height) ?? + BoxConstraints.tightFor(width: width, height: height) + : constraints; /// Style of the the apple payment button. /// @@ -84,11 +84,11 @@ class ApplePayButton extends StatelessWidget { @override Widget build(BuildContext context) => ConstrainedBox( - constraints: constraints ?? - const BoxConstraints.tightFor( - height: _kApplePayButtonDefaultHeight), - child: _platform, - ); + constraints: + constraints ?? + const BoxConstraints.tightFor(height: _kApplePayButtonDefaultHeight), + child: _platform, + ); Widget get _platform { switch (defaultTargetPlatform) { @@ -105,7 +105,8 @@ class ApplePayButton extends StatelessWidget { ); default: throw UnsupportedError( - 'This platform $defaultTargetPlatform does not support Apple Pay'); + 'This platform $defaultTargetPlatform does not support Apple Pay', + ); } } } @@ -145,7 +146,7 @@ class _UiKitApplePayButtonState extends State<_UiKitApplePayButton> { creationParams: { 'type': widget.type.id, 'buttonStyle': widget.style.id, - 'borderRadius': widget.cornerRadius + 'borderRadius': widget.cornerRadius, }, onPlatformViewCreated: (viewId) { methodChannel = MethodChannel('flutter.stripe/apple_pay/$viewId'); @@ -184,8 +185,9 @@ class _UiKitApplePayButtonState extends State<_UiKitApplePayButton> { Stripe.instance.debugUpdatePlatformSheetCalled = false; return true; }()); - final args = - Map.from(call.arguments['shippingMethod']); + final args = Map.from( + call.arguments['shippingMethod'], + ); final newShippingMethod = ApplePayShippingMethod.fromJson(args); await widget.onShippingMethodSelected!.call(newShippingMethod); diff --git a/packages/stripe/lib/src/widgets/aubecs_debit_form.dart b/packages/stripe/lib/src/widgets/aubecs_debit_form.dart index 7ce2dce00..01b6b975e 100644 --- a/packages/stripe/lib/src/widgets/aubecs_debit_form.dart +++ b/packages/stripe/lib/src/widgets/aubecs_debit_form.dart @@ -133,21 +133,22 @@ class _AubecsFormFieldState extends State<_AubecsFormField> { return AndroidViewSurface( controller: controller as AndroidViewController, hitTestBehavior: PlatformViewHitTestBehavior.opaque, - gestureRecognizers: const >{}, + gestureRecognizers: + const >{}, ); }, onCreatePlatformView: (params) { onPlatformViewCreated(params.id); return PlatformViewsService.initExpensiveAndroidView( - id: params.id, - viewType: _viewType, - layoutDirection: TextDirection.ltr, - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - ) + id: params.id, + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + ) ..addOnPlatformViewCreatedListener( - params.onPlatformViewCreated) + params.onPlatformViewCreated, + ) ..create(); }, viewType: _viewType, diff --git a/packages/stripe/lib/src/widgets/card_form_field.dart b/packages/stripe/lib/src/widgets/card_form_field.dart index f543add87..4791033a6 100644 --- a/packages/stripe/lib/src/widgets/card_form_field.dart +++ b/packages/stripe/lib/src/widgets/card_form_field.dart @@ -110,9 +110,8 @@ mixin CardFormFieldContext { class CardFormEditController extends ChangeNotifier { CardFormEditController({CardFieldInputDetails? initialDetails}) - : _initalDetails = initialDetails, - _details = - initialDetails ?? const CardFieldInputDetails(complete: false); + : _initalDetails = initialDetails, + _details = initialDetails ?? const CardFieldInputDetails(complete: false); final CardFieldInputDetails? _initalDetails; CardFieldInputDetails _details; @@ -151,14 +150,18 @@ class CardFormEditController extends ChangeNotifier { CardFormFieldContext? _context; CardFormFieldContext get context { assert( - _context != null, 'CardEditController is not attached to any CardView'); + _context != null, + 'CardEditController is not attached to any CardView', + ); return _context!; } } class _CardFormFieldState extends State { - final FocusNode _node = - FocusNode(debugLabel: 'CardFormField', descendantsAreFocusable: false); + final FocusNode _node = FocusNode( + debugLabel: 'CardFormField', + descendantsAreFocusable: false, + ); CardFormEditController? _fallbackController; CardFormEditController get controller { @@ -229,11 +232,11 @@ class _MethodChannelCardFormField extends StatefulWidget { this.disabled = false, this.preferredNetworks, this.countryCode, - }) : assert(constraints == null || constraints.debugAssertIsValid()), - constraints = (width != null || height != null) - ? constraints?.tighten(width: width, height: height) ?? - BoxConstraints.tightFor(width: width, height: height) - : constraints; + }) : assert(constraints == null || constraints.debugAssertIsValid()), + constraints = (width != null || height != null) + ? constraints?.tighten(width: width, height: height) ?? + BoxConstraints.tightFor(width: width, height: height) + : constraints; final BoxConstraints? constraints; final CardFocusCallback? onFocus; @@ -264,15 +267,14 @@ class _MethodChannelCardFormField extends StatefulWidget { } class _MethodChannelCardFormFieldState - extends State<_MethodChannelCardFormField> with CardFormFieldContext { + extends State<_MethodChannelCardFormField> + with CardFormFieldContext { MethodChannel? _methodChannel; CardFormStyle? _lastStyle; CardFormStyle resolveStyle(CardFormStyle? style) { - return CardFormStyle( - backgroundColor: Colors.transparent, - ).apply(style); + return CardFormStyle(backgroundColor: Colors.transparent).apply(style); } CardFormEditController get controller => widget.controller; @@ -284,8 +286,10 @@ class _MethodChannelCardFormFieldState if (!widget.dangerouslyUpdateFullCardDetails) { if (kDebugMode && controller.details != const CardFieldInputDetails(complete: false)) { - dev.log('WARNING! Initial card data value has been ignored. \n' - '$kDebugPCIMessage'); + dev.log( + 'WARNING! Initial card data value has been ignored. \n' + '$kDebugPCIMessage', + ); } ambiguate(WidgetsBinding.instance)?.addPostFrameCallback((timeStamp) { controller._updateDetails(const CardFieldInputDetails(complete: false)); @@ -317,12 +321,11 @@ class _MethodChannelCardFormFieldState 'cardDetails': controller._initalDetails?.toJson(), 'autofocus': widget.autofocus, if (widget.preferredNetworks != null) - 'preferredNetworks': - widget.preferredNetworks?.map((e) => e.brandValue).toList(), + 'preferredNetworks': widget.preferredNetworks + ?.map((e) => e.brandValue) + .toList(), 'disabled': widget.disabled, - 'defaultValues': { - 'countryCode': widget.countryCode, - } + 'defaultValues': {'countryCode': widget.countryCode}, }; Widget platform; @@ -359,16 +362,15 @@ class _MethodChannelCardFormFieldState } else { throw UnsupportedError('Unsupported platform view'); } - final constraints = widget.constraints ?? + final constraints = + widget.constraints ?? BoxConstraints.expand( - height: defaultTargetPlatform == TargetPlatform.iOS - ? kCardFormFieldDefaultIOSHeight - : kCardFormFieldDefaultAndroidHeight); + height: defaultTargetPlatform == TargetPlatform.iOS + ? kCardFormFieldDefaultIOSHeight + : kCardFormFieldDefaultAndroidHeight, + ); - return ConstrainedBox( - constraints: constraints, - child: platform, - ); + return ConstrainedBox(constraints: constraints, child: platform); } @override @@ -387,8 +389,10 @@ class _MethodChannelCardFormFieldState @override void didUpdateWidget(covariant _MethodChannelCardFormField oldWidget) { if (widget.controller != oldWidget.controller) { - assert(controller._context == null, - 'CardEditController is already attached to a CardView'); + assert( + controller._context == null, + 'CardEditController is already attached to a CardView', + ); oldWidget.controller._context = this; controller._context = this; } @@ -399,14 +403,9 @@ class _MethodChannelCardFormFieldState } if (widget.countryCode != oldWidget.countryCode) { - _methodChannel?.invokeMethod( - 'onDefaultValuesChanged', - { - 'defaultValues': { - 'countryCode': widget.countryCode, - } - }, - ); + _methodChannel?.invokeMethod('onDefaultValuesChanged', { + 'defaultValues': {'countryCode': widget.countryCode}, + }); } if (widget.dangerouslyGetFullCardDetails != oldWidget.dangerouslyGetFullCardDetails) { @@ -446,13 +445,16 @@ class _MethodChannelCardFormFieldState widget.onCardChanged?.call(details); } else { final details = CardFieldInputDetails.fromJson( - Map.from(map['card'])); + Map.from(map['card']), + ); controller._updateDetails(details); widget.onCardChanged?.call(details); } // ignore: avoid_catches_without_on_clauses } catch (e) { - log('An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new'); + log( + 'An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new', + ); rethrow; } } @@ -470,7 +472,9 @@ class _MethodChannelCardFormFieldState widget.onFocus?.call(field.focusedField); // ignore: avoid_catches_without_on_clauses } catch (e) { - log('An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new'); + log( + 'An error ocurred while while parsing card arguments, this should not happen, please consider creating an issue at https://github.com/flutter-stripe/flutter_stripe/issues/new', + ); rethrow; } } @@ -540,24 +544,25 @@ class _AndroidCardFormField extends StatelessWidget { return PlatformViewLink( viewType: viewType, surfaceFactory: (context, controller) => AndroidViewSurface( - controller: controller - // ignore: avoid_as - as AndroidViewController, + controller: + controller + // ignore: avoid_as + as AndroidViewController, gestureRecognizers: const >{}, hitTestBehavior: PlatformViewHitTestBehavior.opaque, ), onCreatePlatformView: (params) { onPlatformViewCreated(params.id); return PlatformViewsService.initExpensiveAndroidView( - id: params.id, - viewType: viewType, - layoutDirection: Directionality.of(context), - creationParams: creationParams, - creationParamsCodec: const StandardMessageCodec(), - onFocus: () { - params.onFocusChanged(true); - }, - ) + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () { + params.onFocusChanged(true); + }, + ) ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) ..create(); }, diff --git a/packages/stripe/lib/src/widgets/embedded_payment_element.dart b/packages/stripe/lib/src/widgets/embedded_payment_element.dart new file mode 100644 index 000000000..e8dc06291 --- /dev/null +++ b/packages/stripe/lib/src/widgets/embedded_payment_element.dart @@ -0,0 +1,378 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; + +/// Called when the user selects or clears a payment method. +typedef PaymentOptionChangedCallback = + void Function(PaymentSheetPaymentOption? paymentOption); + +/// Called when the embedded payment element's height changes. +typedef HeightChangedCallback = void Function(double height); + +/// Called when the embedded payment element fails to load. +typedef LoadingFailedCallback = void Function( + EmbeddedPaymentElementLoadingException error, +); + +/// Called when form sheet confirmation completes. +typedef FormSheetConfirmCompleteCallback = + void Function(Map result); + +/// Called when a row is selected with immediate action behavior. +typedef RowSelectionImmediateActionCallback = void Function(); + +/// Structured error returned when the embedded payment element fails to load. +@immutable +class EmbeddedPaymentElementLoadingException implements Exception { + const EmbeddedPaymentElementLoadingException({ + required this.message, + this.code, + this.details, + }); + + /// Human-readable description for displaying to the user. + final String message; + + /// Error code returned by the platform, when available. + final String? code; + + /// Additional diagnostic information from the native SDK, if provided. + final Map? details; + + @override + String toString() => + 'EmbeddedPaymentElementLoadingException(message: $message, code: $code, details: $details)'; +} + +/// A widget that displays Stripe's Embedded Payment Element. +/// +/// Allows users to select and configure payment methods inline within your app. +/// Supports saved payment methods, new cards, Apple Pay, Google Pay, and more. +/// +/// Only supported on iOS and Android platforms. +class EmbeddedPaymentElement extends StatefulWidget { + /// Creates an embedded payment element. + const EmbeddedPaymentElement({ + required this.intentConfiguration, + required this.configuration, + this.controller, + this.onPaymentOptionChanged, + this.onHeightChanged, + this.onLoadingFailed, + this.onFormSheetConfirmComplete, + this.onRowSelectionImmediateAction, + super.key, + this.androidPlatformViewRenderType = + AndroidPlatformViewRenderType.expensiveAndroidView, + }); + + /// Configuration for creating the payment or setup intent. + final IntentConfiguration intentConfiguration; + + /// Configuration for appearance and behavior. + final SetupPaymentSheetParameters configuration; + + /// Optional controller for programmatic control. + final EmbeddedPaymentElementController? controller; + + /// Called when payment method selection changes. + final PaymentOptionChangedCallback? onPaymentOptionChanged; + + /// Called when the element's height changes. + final HeightChangedCallback? onHeightChanged; + + /// Called when loading fails. + final LoadingFailedCallback? onLoadingFailed; + + /// Called when form sheet confirmation completes. + final FormSheetConfirmCompleteCallback? onFormSheetConfirmComplete; + + /// Called when row selection triggers immediate action. + final RowSelectionImmediateActionCallback? onRowSelectionImmediateAction; + + /// Android platform view rendering mode. + final AndroidPlatformViewRenderType androidPlatformViewRenderType; + + @override + State createState() => _EmbeddedPaymentElementState(); +} + +class _EmbeddedPaymentElementState extends State + implements EmbeddedPaymentElementContext { + EmbeddedPaymentElementController? _fallbackController; + EmbeddedPaymentElementController get controller { + if (widget.controller != null) return widget.controller!; + _fallbackController ??= EmbeddedPaymentElementController(); + return _fallbackController!; + } + + MethodChannel? _methodChannel; + double _currentHeight = 0; + + @override + void initState() { + super.initState(); + controller.attach(this); + if (widget.intentConfiguration.confirmHandler != null) { + Stripe.instance.setConfirmHandler( + widget.intentConfiguration.confirmHandler!, + ); + } + } + + @override + void dispose() { + controller.detach(this); + _fallbackController?.dispose(); + if (widget.intentConfiguration.confirmHandler != null) { + Stripe.instance.setConfirmHandler(null); + } + super.dispose(); + } + + @override + void didUpdateWidget(EmbeddedPaymentElement oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.detach(this); + controller.attach(this); + } + } + + @override + Future?> confirm() async { + final result = await _methodChannel?.invokeMethod('confirm'); + if (result is Map) { + return Map.from(result); + } + return null; + } + + @override + Future clearPaymentOption() async { + await _methodChannel?.invokeMethod('clearPaymentOption'); + } + + void _onPlatformViewCreated(int viewId) { + _methodChannel = MethodChannel( + 'flutter.stripe/embedded_payment_element/$viewId', + ); + _methodChannel?.setMethodCallHandler(_handleMethodCall); + } + + Future _handleMethodCall(MethodCall call) async { + try { + switch (call.method) { + case 'onPaymentOptionChanged': + final arguments = call.arguments as Map?; + if (arguments != null) { + final paymentOptionMap = Map.from( + arguments['paymentOption'] ?? {}, + ); + if (paymentOptionMap.isNotEmpty) { + final paymentOption = PaymentSheetPaymentOption.fromJson( + paymentOptionMap, + ); + widget.onPaymentOptionChanged?.call(paymentOption); + } else { + widget.onPaymentOptionChanged?.call(null); + } + } + break; + case 'onHeightChanged': + final arguments = call.arguments as Map?; + if (arguments != null) { + final height = (arguments['height'] as num?)?.toDouble() ?? 0; + if (height <= 0) return; + + setState(() { + _currentHeight = height; + }); + widget.onHeightChanged?.call(height); + } + break; + case 'embeddedPaymentElementLoadingFailed': + final error = _parseLoadingError(call.arguments); + widget.onLoadingFailed?.call(error); + break; + case 'embeddedPaymentElementFormSheetConfirmComplete': + final arguments = call.arguments as Map?; + if (arguments != null) { + final result = Map.from(arguments); + widget.onFormSheetConfirmComplete?.call(result); + } + break; + case 'embeddedPaymentElementRowSelectionImmediateAction': + widget.onRowSelectionImmediateAction?.call(); + break; + } + } catch (e) { + debugPrint('Error handling method call ${call.method}: $e'); + } + } + + EmbeddedPaymentElementLoadingException _parseLoadingError(dynamic payload) { + if (payload is Map) { + final map = {}; + for (final entry in payload.entries) { + if (entry.key is String) { + map[entry.key as String] = entry.value; + } else { + map['${entry.key}'] = entry.value; + } + } + + var message = (map['localizedMessage'] as String?) ?? + (map['message'] as String?); + final code = map['code'] as String?; + final detailsRaw = map['details']; + Map? details; + if (detailsRaw is Map) { + details = {}; + for (final entry in detailsRaw.entries) { + if (entry.key is String) { + details![entry.key as String] = entry.value; + } else { + details!['${entry.key}'] = entry.value; + } + } + message ??= (details?['localizedMessage'] as String?) ?? + (details?['message'] as String?); + } + message ??= 'Unknown error'; + return EmbeddedPaymentElementLoadingException( + message: message, + code: code, + details: details, + ); + } + + final message = payload is String && payload.isNotEmpty + ? payload + : 'Unknown error'; + return EmbeddedPaymentElementLoadingException(message: message); + } + + @override + Widget build(BuildContext context) { + final creationParams = { + 'intentConfiguration': widget.intentConfiguration.toJson(), + 'configuration': widget.configuration.toJson(), + }; + + Widget platformView; + if (defaultTargetPlatform == TargetPlatform.android) { + platformView = _AndroidEmbeddedPaymentElement( + viewType: 'flutter.stripe/embedded_payment_element', + creationParams: creationParams, + onPlatformViewCreated: _onPlatformViewCreated, + androidPlatformViewRenderType: widget.androidPlatformViewRenderType, + ); + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + platformView = _UiKitEmbeddedPaymentElement( + viewType: 'flutter.stripe/embedded_payment_element', + creationParams: creationParams, + onPlatformViewCreated: _onPlatformViewCreated, + ); + } else { + throw UnsupportedError( + 'Embedded Payment Element is not supported on this platform', + ); + } + + return AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: SizedBox( + height: _currentHeight > 0 ? _currentHeight : 400, + child: platformView, + ), + ); + } +} + +class _AndroidEmbeddedPaymentElement extends StatelessWidget { + const _AndroidEmbeddedPaymentElement({ + required this.viewType, + required this.creationParams, + required this.onPlatformViewCreated, + required this.androidPlatformViewRenderType, + }); + + final String viewType; + final Map creationParams; + final PlatformViewCreatedCallback onPlatformViewCreated; + final AndroidPlatformViewRenderType androidPlatformViewRenderType; + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: PlatformViewLink( + viewType: viewType, + surfaceFactory: (context, controller) => AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ), + onCreatePlatformView: (params) { + onPlatformViewCreated(params.id); + switch (androidPlatformViewRenderType) { + case AndroidPlatformViewRenderType.expensiveAndroidView: + return PlatformViewsService.initExpensiveAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + case AndroidPlatformViewRenderType.androidView: + return PlatformViewsService.initAndroidView( + id: params.id, + viewType: viewType, + layoutDirection: Directionality.of(context), + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + } + }, + ), + ); + } +} + +class _UiKitEmbeddedPaymentElement extends StatelessWidget { + const _UiKitEmbeddedPaymentElement({ + required this.viewType, + required this.creationParams, + required this.onPlatformViewCreated, + }); + + final String viewType; + final Map creationParams; + final PlatformViewCreatedCallback onPlatformViewCreated; + + @override + Widget build(BuildContext context) { + return ClipRect( + clipBehavior: Clip.hardEdge, + child: RepaintBoundary( + child: UiKitView( + viewType: viewType, + creationParamsCodec: const StandardMessageCodec(), + creationParams: creationParams, + onPlatformViewCreated: onPlatformViewCreated, + ), + ), + ); + } +} diff --git a/packages/stripe/lib/src/widgets/embedded_payment_element_controller.dart b/packages/stripe/lib/src/widgets/embedded_payment_element_controller.dart new file mode 100644 index 000000000..ee09d6a1e --- /dev/null +++ b/packages/stripe/lib/src/widgets/embedded_payment_element_controller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +class EmbeddedPaymentElementController extends ChangeNotifier { + EmbeddedPaymentElementController(); + + EmbeddedPaymentElementContext? _context; + + bool get hasEmbeddedPaymentElement => _context != null; + + void attach(EmbeddedPaymentElementContext context) { + assert( + !hasEmbeddedPaymentElement, + 'Controller is already attached to an EmbeddedPaymentElement', + ); + _context = context; + } + + void detach(EmbeddedPaymentElementContext context) { + if (_context == context) { + _context = null; + } + } + + Future?> confirm() async { + assert( + hasEmbeddedPaymentElement, + 'Controller must be attached to an EmbeddedPaymentElement', + ); + return await _context?.confirm(); + } + + Future clearPaymentOption() async { + assert( + hasEmbeddedPaymentElement, + 'Controller must be attached to an EmbeddedPaymentElement', + ); + await _context?.clearPaymentOption(); + } + + @override + void dispose() { + _context = null; + super.dispose(); + } +} + +abstract class EmbeddedPaymentElementContext { + Future?> confirm(); + Future clearPaymentOption(); +} diff --git a/packages/stripe/pubspec.yaml b/packages/stripe/pubspec.yaml index 177707c83..a872c6bdc 100644 --- a/packages/stripe/pubspec.yaml +++ b/packages/stripe/pubspec.yaml @@ -22,9 +22,12 @@ dependencies: flutter: sdk: flutter meta: ^1.8.0 - stripe_android: ^12.0.1 - stripe_ios: ^12.0.1 - stripe_platform_interface: ^12.0.0 + stripe_android: + path: ../stripe_android + stripe_ios: + path: ../stripe_ios + stripe_platform_interface: + path: ../stripe_platform_interface dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/stripe_android/android/build.gradle b/packages/stripe_android/android/build.gradle index 1daa0a496..9bd6e715a 100644 --- a/packages/stripe_android/android/build.gradle +++ b/packages/stripe_android/android/build.gradle @@ -2,8 +2,15 @@ group 'com.flutter.stripe' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.8.0' + def kotlin_version = rootProject.ext.has('kotlinVersion') + ? rootProject.ext.get('kotlinVersion') + : '1.9.0' + + ext.kotlin_version = kotlin_version ext.stripe_version = '21.26.+' + ext.compose_version = '1.5.1' + + def kotlinMajor = kotlin_version.tokenize('\\.')[0].toInteger() repositories { google() @@ -13,6 +20,10 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // only use compose-compiler plugin when Kotlin >= 2.0 + if (kotlinMajor >= 2) { + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" + } } } @@ -23,8 +34,17 @@ rootProject.allprojects { } } +def kotlin_version = rootProject.ext.has('kotlinVersion') + ? rootProject.ext.get('kotlinVersion') + : '1.9.0' +def kotlinMajor = kotlin_version.tokenize('\\.')[0].toInteger() + apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +// Only apply compose plugin if Kotlin >= 2.0 +if (kotlinMajor >= 2) { + apply plugin: 'org.jetbrains.kotlin.plugin.compose' +} android { namespace 'com.flutter.stripe' @@ -39,6 +59,14 @@ android { jvmTarget = '17' } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = '1.5.1' + } + sourceSets { main.java.srcDirs += 'src/main/kotlin' } @@ -59,6 +87,13 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' + // Jetpack Compose dependencies for EmbeddedPaymentElement + implementation platform('androidx.compose:compose-bom:2023.10.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.foundation:foundation' + implementation 'androidx.compose.runtime:runtime' + implementation 'androidx.activity:activity-compose:1.8.0' + // play-services-wallet is already included in stripe-android compileOnly "com.google.android.gms:play-services-wallet:19.3.0" diff --git a/packages/stripe_android/android/gradle/wrapper/gradle-wrapper.properties b/packages/stripe_android/android/gradle/wrapper/gradle-wrapper.properties index cb086a5fc..e2847c820 100644 --- a/packages/stripe_android/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/stripe_android/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-all.zip diff --git a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeAndroidPlugin.kt b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeAndroidPlugin.kt index 5d3f9fc8a..6436ed608 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeAndroidPlugin.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeAndroidPlugin.kt @@ -11,6 +11,7 @@ import com.google.android.material.internal.ThemeEnforcement import com.reactnativestripesdk.* import com.reactnativestripesdk.addresssheet.AddressSheetViewManager import com.reactnativestripesdk.pushprovisioning.AddToWalletButtonManager +import com.reactnativestripesdk.EmbeddedPaymentElementViewManager import com.reactnativestripesdk.utils.getIntOrNull import com.reactnativestripesdk.utils.getValOr import com.stripe.android.model.PaymentMethodCreateParams @@ -57,6 +58,10 @@ class StripeAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { AddressSheetViewManager() } + private val embeddedPaymentElementViewManager: EmbeddedPaymentElementViewManager by lazy { + EmbeddedPaymentElementViewManager() + } + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(flutterPluginBinding.applicationContext) @@ -78,6 +83,9 @@ class StripeAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { .platformViewRegistry .registerViewFactory("flutter.stripe/add_to_wallet", StripeAddToWalletPlatformViewFactory(flutterPluginBinding, AddToWalletButtonManager(flutterPluginBinding.applicationContext)){stripeSdk}) flutterPluginBinding.platformViewRegistry.registerViewFactory("flutter.stripe/address_sheet", StripeAddressSheetPlatformViewFactory(flutterPluginBinding, addressSheetFormViewManager ){stripeSdk}) + flutterPluginBinding + .platformViewRegistry + .registerViewFactory("flutter.stripe/embedded_payment_element", StripeSdkEmbeddedPaymentElementPlatformViewFactory(flutterPluginBinding, embeddedPaymentElementViewManager){stripeSdk}) } override fun onMethodCall(call: MethodCall, result: Result) { diff --git a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt new file mode 100644 index 000000000..ad1cbd5e3 --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt @@ -0,0 +1,112 @@ +package com.flutter.stripe + +import android.content.Context +import android.view.View +import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativestripesdk.EmbeddedPaymentElementLoadingError +import com.reactnativestripesdk.EmbeddedPaymentElementView +import com.reactnativestripesdk.EmbeddedPaymentElementViewManager +import com.reactnativestripesdk.StripeSdkModule +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.platform.PlatformView + +class StripeSdkEmbeddedPaymentElementPlatformView( + private val context: Context, + channel: MethodChannel, + id: Int, + creationParams: Map?, + private val viewManager: EmbeddedPaymentElementViewManager, + sdkAccessor: () -> StripeSdkModule +) : PlatformView, MethodChannel.MethodCallHandler { + + private val themedContext = ThemedReactContext(sdkAccessor().reactContext, channel, sdkAccessor) + private val embeddedView: EmbeddedPaymentElementView = viewManager.createViewInstance(themedContext) + + init { + channel.setMethodCallHandler(this) + + embeddedView.onHeightChanged = { height -> + channel.invokeMethod("onHeightChanged", mapOf("height" to height.toDouble())) + } + + embeddedView.onPaymentOptionChanged = { paymentOption -> + channel.invokeMethod("onPaymentOptionChanged", mapOf("paymentOption" to paymentOption)) + } + + embeddedView.onLoadingFailed = { error: EmbeddedPaymentElementLoadingError -> + channel.invokeMethod("embeddedPaymentElementLoadingFailed", error.toMap()) + } + + embeddedView.onRowSelectionImmediateAction = { + channel.invokeMethod("embeddedPaymentElementRowSelectionImmediateAction", null) + } + + embeddedView.onFormSheetConfirmComplete = { result -> + channel.invokeMethod("embeddedPaymentElementFormSheetConfirmComplete", result) + } + + creationParams?.let { params -> + val configMap = params["configuration"] as? Map<*, *> + val intentConfigMap = params["intentConfiguration"] as? Map<*, *> + + if (configMap != null) { + @Suppress("UNCHECKED_CAST") + val configBundle = mapToBundle(configMap as Map) + val rowSelectionBehaviorType = viewManager.parseRowSelectionBehavior(configBundle) + embeddedView.rowSelectionBehaviorType.value = rowSelectionBehaviorType + val elementConfig = viewManager.parseElementConfiguration(configBundle, context) + embeddedView.latestElementConfig = elementConfig + } + + if (intentConfigMap != null) { + @Suppress("UNCHECKED_CAST") + val intentConfigBundle = mapToBundle(intentConfigMap as Map) + val intentConfig = viewManager.parseIntentConfiguration(intentConfigBundle) + embeddedView.latestIntentConfig = intentConfig + } + + if (embeddedView.latestElementConfig != null && embeddedView.latestIntentConfig != null) { + embeddedView.configure(embeddedView.latestElementConfig!!, embeddedView.latestIntentConfig!!) + embeddedView.post { + embeddedView.requestLayout() + embeddedView.invalidate() + } + } + } + } + + override fun getView(): View { + return embeddedView + } + + override fun dispose() { + viewManager.onDropViewInstance(embeddedView) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "confirm" -> { + embeddedView.onConfirmResult = { resultMap -> + result.success(resultMap) + embeddedView.onConfirmResult = null + } + viewManager.confirm(embeddedView) + } + "clearPaymentOption" -> { + viewManager.clearPaymentOption(embeddedView) + result.success(null) + } + else -> { + result.notImplemented() + } + } + } + + override fun onFlutterViewAttached(flutterView: View) { + embeddedView.post { + embeddedView.requestLayout() + embeddedView.invalidate() + } + } +} diff --git a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformViewFactory.kt b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformViewFactory.kt new file mode 100644 index 000000000..0442f87a2 --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformViewFactory.kt @@ -0,0 +1,36 @@ +package com.flutter.stripe + +import android.content.Context +import com.reactnativestripesdk.EmbeddedPaymentElementViewManager +import com.reactnativestripesdk.StripeSdkModule +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class StripeSdkEmbeddedPaymentElementPlatformViewFactory( + private val flutterPluginBinding: FlutterPlugin.FlutterPluginBinding, + private val embeddedPaymentElementViewManager: EmbeddedPaymentElementViewManager, + private val sdkAccessor: () -> StripeSdkModule +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + + override fun create(context: Context?, viewId: Int, args: Any?): PlatformView { + val channel = MethodChannel( + flutterPluginBinding.binaryMessenger, + "flutter.stripe/embedded_payment_element/${viewId}" + ) + val creationParams = args as? Map? + if (context == null) { + throw AssertionError("Context is not allowed to be null when launching embedded payment element view.") + } + return StripeSdkEmbeddedPaymentElementPlatformView( + context, + channel, + viewId, + creationParams, + embeddedPaymentElementViewManager, + sdkAccessor + ) + } +} diff --git a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkModuleExtensions.kt b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkModuleExtensions.kt index b0cae0f3b..e3ae98c10 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkModuleExtensions.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkModuleExtensions.kt @@ -55,3 +55,49 @@ fun Any.convertToReadable(): Any { else -> this } } + +fun mapToBundle(map: Map?): android.os.Bundle { + val result = android.os.Bundle() + if (map == null) { + return result + } + + for ((key, value) in map) { + if (key == null) continue + + when (value) { + null -> result.putString(key, null) + is Boolean -> result.putBoolean(key, value) + is Int -> result.putInt(key, value) + is Long -> result.putLong(key, value) + is Double -> result.putDouble(key, value) + is String -> result.putString(key, value) + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + result.putBundle(key, mapToBundle(value as Map)) + } + is List<*> -> { + val list = value as List<*> + if (list.isEmpty()) { + result.putStringArrayList(key, ArrayList()) + } else { + when (list.first()) { + is String -> { + @Suppress("UNCHECKED_CAST") + result.putStringArrayList(key, ArrayList(list as List)) + } + is Int -> { + @Suppress("UNCHECKED_CAST") + result.putIntegerArrayList(key, ArrayList(list as List)) + } + else -> { + android.util.Log.e("mapToBundle", "Cannot put arrays of objects into bundles. Failed on: $key.") + } + } + } + } + else -> android.util.Log.e("mapToBundle", "Could not convert object with key: $key, type: ${value::class.java}") + } + } + return result +} diff --git a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt new file mode 100644 index 000000000..831a64d97 --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt @@ -0,0 +1,472 @@ +package com.reactnativestripesdk + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativestripesdk.toWritableMap +import com.reactnativestripesdk.utils.KeepJsAwakeTask +import com.reactnativestripesdk.utils.mapFromCustomPaymentMethod +import com.reactnativestripesdk.utils.mapFromPaymentMethod +import com.stripe.android.core.exception.StripeException +import com.stripe.android.model.PaymentMethod +import com.stripe.android.paymentelement.CustomPaymentMethodResult +import com.stripe.android.paymentelement.CustomPaymentMethodResultHandler +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.ExperimentalCustomPaymentMethodsApi +import com.stripe.android.paymentelement.rememberEmbeddedPaymentElement +import com.stripe.android.paymentsheet.CreateIntentResult +import com.stripe.android.paymentsheet.PaymentSheet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch + +enum class RowSelectionBehaviorType { + Default, + ImmediateAction, +} + +data class EmbeddedPaymentElementLoadingError( + val message: String, + val code: String?, + val details: Map?, +) { + fun toMap(): Map { + val payload = mutableMapOf("message" to message) + code?.let { payload["code"] = it } + details?.let { payload["details"] = it } + return payload + } + + fun toWritableMap(): WritableMap { + val map = Arguments.createMap() + map.putString("message", message) + if (code != null) { + map.putString("code", code) + } else { + map.putNull("code") + } + details?.let { map.putMap("details", it.toWritableMapDynamic()) } + return map + } +} + +@OptIn(ExperimentalCustomPaymentMethodsApi::class) +class EmbeddedPaymentElementView( + context: Context, +) : StripeAbstractComposeView(context) { + private sealed interface Event { + data class Configure( + val configuration: EmbeddedPaymentElement.Configuration, + val intentConfiguration: PaymentSheet.IntentConfiguration, + ) : Event + + data object Confirm : Event + + data object ClearPaymentOption : Event + } + + var latestIntentConfig: PaymentSheet.IntentConfiguration? = null + var latestElementConfig: EmbeddedPaymentElement.Configuration? = null + + val rowSelectionBehaviorType = mutableStateOf(null) + + var onConfirmResult: ((Map) -> Unit)? = null + var onHeightChanged: ((Float) -> Unit)? = null + var onPaymentOptionChanged: ((Map?) -> Unit)? = null + var onLoadingFailed: ((EmbeddedPaymentElementLoadingError) -> Unit)? = null + var onRowSelectionImmediateAction: (() -> Unit)? = null + var onFormSheetConfirmComplete: ((Map) -> Unit)? = null + + private val reactContext get() = context as ThemedReactContext + private val events = Channel(Channel.UNLIMITED) + + @OptIn(ExperimentalCustomPaymentMethodsApi::class) + @Composable + override fun Content() { + val type by remember { rowSelectionBehaviorType } + val coroutineScope = rememberCoroutineScope() + + val confirmCustomPaymentMethodCallback = + remember(coroutineScope) { + { + customPaymentMethod: PaymentSheet.CustomPaymentMethod, + billingDetails: PaymentMethod.BillingDetails, + -> + // Launch a transparent Activity to ensure React Native UI can appear on top of the Stripe proxy activity. + try { + val intent = + Intent(reactContext, CustomPaymentMethodActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + } + reactContext.startActivity(intent) + } catch (e: Exception) { + Log.e("StripeReactNative", "Failed to start CustomPaymentMethodActivity", e) + } + + val stripeSdkModule = + try { + requireStripeSdkModule() + } catch (ex: IllegalArgumentException) { + Log.e("StripeReactNative", "StripeSdkModule not found for CPM callback", ex) + CustomPaymentMethodActivity.finishCurrent() + return@remember + } + + // Keep JS awake while React Native is backgrounded by Stripe SDK. + val keepJsAwakeTask = + KeepJsAwakeTask(reactContext.reactApplicationContext).apply { start() } + + // Run on coroutine scope. + coroutineScope.launch { + try { + // Give the CustomPaymentMethodActivity a moment to fully initialize + delay(100) + + // Emit event so JS can show the Alert and eventually respond via `customPaymentMethodResultCallback`. + stripeSdkModule.eventEmitter.emitOnCustomPaymentMethodConfirmHandlerCallback( + mapFromCustomPaymentMethod(customPaymentMethod, billingDetails), + ) + + // Await JS result. + val resultFromJs = stripeSdkModule.customPaymentMethodResultCallback.await() + + keepJsAwakeTask.stop() + + val status = resultFromJs.getString("status") + + val nativeResult = + when (status) { + "completed" -> + CustomPaymentMethodResult + .completed() + "canceled" -> + CustomPaymentMethodResult + .canceled() + "failed" -> { + val errMsg = resultFromJs.getString("error") ?: "Custom payment failed" + CustomPaymentMethodResult + .failed(displayMessage = errMsg) + } + else -> + CustomPaymentMethodResult + .failed(displayMessage = "Unknown status") + } + + // Return result to Stripe SDK. + CustomPaymentMethodResultHandler.handleCustomPaymentMethodResult( + reactContext, + nativeResult, + ) + } finally { + // Clean up the transparent activity + CustomPaymentMethodActivity.finishCurrent() + } + } + } + } + + val builder = + remember(type) { + EmbeddedPaymentElement + .Builder( + createIntentCallback = { paymentMethod, shouldSavePaymentMethod -> + val stripeSdkModule = + try { + requireStripeSdkModule() + } catch (ex: IllegalArgumentException) { + return@Builder CreateIntentResult.Failure( + cause = + Exception( + "Tried to call confirmHandler, but no callback was found. Please file an issue: https://github.com/stripe/stripe-react-native/issues", + ), + displayMessage = "An unexpected error occurred", + ) + } + + // Make sure that JS is active since the activity will be paused when stripe ui is presented. + val keepJsAwakeTask = + KeepJsAwakeTask(reactContext.reactApplicationContext).apply { start() } + + val params = + Arguments.createMap().apply { + putMap("paymentMethod", mapFromPaymentMethod(paymentMethod)) + putBoolean("shouldSavePaymentMethod", shouldSavePaymentMethod) + } + + stripeSdkModule.eventEmitter.emitOnConfirmHandlerCallback(params) + + val resultFromJavascript = stripeSdkModule.embeddedIntentCreationCallback.await() + // reset the completable + stripeSdkModule.embeddedIntentCreationCallback = CompletableDeferred() + + keepJsAwakeTask.stop() + + resultFromJavascript.getString("clientSecret")?.let { + CreateIntentResult.Success(clientSecret = it) + } ?: run { + val errorMap = resultFromJavascript.getMap("error") + CreateIntentResult.Failure( + cause = Exception(errorMap?.getString("message")), + displayMessage = errorMap?.getString("localizedMessage"), + ) + } + }, + resultCallback = { result -> + val resultMap = when (result) { + is EmbeddedPaymentElement.Result.Completed -> + mapOf("status" to "completed") + is EmbeddedPaymentElement.Result.Canceled -> + mapOf("status" to "canceled") + is EmbeddedPaymentElement.Result.Failed -> + mapOf("status" to "failed", "error" to (result.error.message ?: "Unknown error")) + } + + onConfirmResult?.invoke(resultMap) + + onFormSheetConfirmComplete?.invoke(resultMap) ?: run { + val map = + Arguments.createMap().apply { + when (result) { + is EmbeddedPaymentElement.Result.Completed -> { + putString("status", "completed") + } + is EmbeddedPaymentElement.Result.Canceled -> { + putString("status", "canceled") + } + is EmbeddedPaymentElement.Result.Failed -> { + putString("status", "failed") + putString("error", result.error.message ?: "Unknown error") + } + } + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementFormSheetConfirmComplete(map) + } + }, + ).confirmCustomPaymentMethodCallback(confirmCustomPaymentMethodCallback) + .rowSelectionBehavior( + if (type == RowSelectionBehaviorType.Default) { + EmbeddedPaymentElement.RowSelectionBehavior.default() + } else { + EmbeddedPaymentElement.RowSelectionBehavior.immediateAction { + onRowSelectionImmediateAction?.invoke() ?: run { + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementRowSelectionImmediateAction() + } + } + }, + ) + } + + val embedded = rememberEmbeddedPaymentElement(builder) + var height by remember { + mutableIntStateOf(0) + } + + // collect events: configure, confirm, clear + LaunchedEffect(Unit) { + events.consumeAsFlow().collect { ev -> + when (ev) { + is Event.Configure -> { + // call configure and grab the result + val result = + embedded.configure( + intentConfiguration = ev.intentConfiguration, + configuration = ev.configuration, + ) + + when (result) { + is EmbeddedPaymentElement.ConfigureResult.Succeeded -> reportHeightChange(1f) + is EmbeddedPaymentElement.ConfigureResult.Failed -> { + val errorPayload = result.error.asEmbeddedPaymentElementLoadingError() + onLoadingFailed?.invoke(errorPayload) ?: run { + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed( + errorPayload.toWritableMap(), + ) + } + } + } + } + + is Event.Confirm -> { + embedded.confirm() + } + is Event.ClearPaymentOption -> { + embedded.clearPaymentOption() + } + } + } + } + + LaunchedEffect(embedded) { + embedded.paymentOption.collect { opt -> + val optMap = opt?.toWritableMap() + onPaymentOptionChanged?.invoke(optMap) ?: run { + val payload = + Arguments.createMap().apply { + putMap("paymentOption", optMap) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdatePaymentOption(payload) + } + } + } + + val density = LocalDensity.current + + Box { + measuredEmbeddedElement( + reportHeightChange = { h -> reportHeightChange(h) }, + ) { + embedded.Content() + } + } + } + + @Composable + private fun measuredEmbeddedElement( + reportHeightChange: (Float) -> Unit, + content: @Composable () -> Unit, + ) { + val density = LocalDensity.current + var heightDp by remember { mutableStateOf(1.dp) } // non-zero sentinel + + Box( + Modifier + // Clamp the host Android view height; drive it in Dp + .requiredHeight(heightDp) + // Post-layout: convert px -> dp, update RN & our dp state + .onSizeChanged { size -> + val h = with(density) { size.height.toDp() } + if (h != heightDp) { + heightDp = h + reportHeightChange(h.value) // send dp as Float to RN + } + } + // Custom measure path: force child to its min intrinsic height (in *px*) + .layout { measurable, constraints -> + val widthPx = constraints.maxWidth + val minHpx = measurable.minIntrinsicHeight(widthPx).coerceAtLeast(1) + + // Measure the child with a tight height equal to min intrinsic + val placeable = + measurable.measure( + constraints.copy( + minHeight = minHpx, + maxHeight = minHpx, + ), + ) + + // Our own size: use the child’s measured size + layout(constraints.maxWidth, placeable.height) { + placeable.placeRelative(IntOffset.Zero) + } + }, + ) { + content() + } + } + + private fun reportHeightChange(height: Float) { + onHeightChanged?.invoke(height) ?: run { + val params = + Arguments.createMap().apply { + putDouble("height", height.toDouble()) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdateHeight(params) + } + } + + // APIs + fun configure( + config: EmbeddedPaymentElement.Configuration, + intentConfig: PaymentSheet.IntentConfiguration, + ) { + events.trySend(Event.Configure(config, intentConfig)) + } + + fun confirm() { + events.trySend(Event.Confirm) + } + + fun clearPaymentOption() { + events.trySend(Event.ClearPaymentOption) + } + + private fun requireStripeSdkModule() = requireNotNull(reactContext.getNativeModule(StripeSdkModule::class.java)) +} + +private fun Throwable.asEmbeddedPaymentElementLoadingError(): EmbeddedPaymentElementLoadingError { + val localized = localizedMessage + val rawMessage = message + val baseMessage = localized ?: rawMessage ?: toString() + val detailsMap = mutableMapOf( + "localizedMessage" to localized, + "message" to rawMessage, + "type" to this::class.qualifiedName, + ) + var code: String? = null + + if (this is StripeException) { + val stripeError = this.stripeError + detailsMap["stripeErrorCode"] = stripeError?.code + detailsMap["stripeErrorMessage"] = stripeError?.message + detailsMap["declineCode"] = stripeError?.declineCode + code = stripeError?.code + } + + cause?.let { + detailsMap["cause"] = it.localizedMessage ?: it.toString() + } + + if (code.isNullOrBlank()) { + code = this::class.simpleName + } + + val filteredDetails = detailsMap.filterValues { it != null } + + return EmbeddedPaymentElementLoadingError( + message = baseMessage, + code = code, + details = if (filteredDetails.isNotEmpty()) filteredDetails else null, + ) +} + +private fun Map.toWritableMapDynamic(): WritableMap { + val map = Arguments.createMap() + for ((key, value) in this) { + when (value) { + null -> map.putNull(key) + is String -> map.putString(key, value) + is Boolean -> map.putBoolean(key, value) + is Int -> map.putInt(key, value) + is Double -> map.putDouble(key, value) + is Float -> map.putDouble(key, value.toDouble()) + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + map.putMap(key, (value as Map).toWritableMapDynamic()) + } + else -> map.putString(key, value.toString()) + } + } + return map +} diff --git a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt new file mode 100644 index 000000000..2f4a94981 --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt @@ -0,0 +1,283 @@ +package com.reactnativestripesdk + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.EmbeddedPaymentElementViewManagerDelegate +import com.facebook.react.viewmanagers.EmbeddedPaymentElementViewManagerInterface +import com.reactnativestripesdk.PaymentSheetFragment.Companion.buildCustomerConfiguration +import com.reactnativestripesdk.PaymentSheetFragment.Companion.buildGooglePayConfig +import com.reactnativestripesdk.addresssheet.AddressSheetView +import com.reactnativestripesdk.utils.PaymentSheetAppearanceException +import com.reactnativestripesdk.utils.PaymentSheetException +import com.reactnativestripesdk.utils.getBooleanOr +import com.reactnativestripesdk.utils.mapToPreferredNetworks +import com.reactnativestripesdk.utils.parseCustomPaymentMethods +import com.reactnativestripesdk.utils.toBundleObject +import com.stripe.android.ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.ExperimentalCustomPaymentMethodsApi +import com.stripe.android.paymentsheet.PaymentSheet + +@ReactModule(name = EmbeddedPaymentElementViewManager.NAME) +class EmbeddedPaymentElementViewManager : + ViewGroupManager(), + EmbeddedPaymentElementViewManagerInterface { + companion object { + const val NAME = "EmbeddedPaymentElementView" + } + + private val delegate = EmbeddedPaymentElementViewManagerDelegate(this) + + override fun getName() = NAME + + override fun getDelegate() = delegate + + override fun createViewInstance(ctx: ThemedReactContext): EmbeddedPaymentElementView = EmbeddedPaymentElementView(ctx) + + override fun onDropViewInstance(view: EmbeddedPaymentElementView) { + super.onDropViewInstance(view) + + view.handleOnDropViewInstance() + } + + override fun needsCustomLayoutForChildren(): Boolean = true + + @ReactProp(name = "configuration") + override fun setConfiguration( + view: EmbeddedPaymentElementView, + cfg: Dynamic, + ) { + val readableMap = cfg.asMap() + if (readableMap == null) return + + val bundle = toBundleObject(readableMap) + val rowSelectionBehaviorType = parseRowSelectionBehavior(bundle) + view.rowSelectionBehaviorType.value = rowSelectionBehaviorType + + val elementConfig = parseElementConfiguration(bundle, view.context) + view.latestElementConfig = elementConfig + view.latestIntentConfig?.let { intentCfg -> + view.configure(elementConfig, intentCfg) + view.post { + view.requestLayout() + view.invalidate() + } + } + } + + @ReactProp(name = "intentConfiguration") + override fun setIntentConfiguration( + view: EmbeddedPaymentElementView, + cfg: Dynamic, + ) { + val readableMap = cfg.asMap() + if (readableMap == null) return + val bundle = toBundleObject(readableMap) + val intentConfig = parseIntentConfiguration(bundle) + view.latestIntentConfig = intentConfig + view.latestElementConfig?.let { elemCfg -> + view.configure(elemCfg, intentConfig) + } + } + + @SuppressLint("RestrictedApi") + @OptIn( + ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi::class, + ExperimentalCustomPaymentMethodsApi::class, + ) + internal fun parseElementConfiguration( + bundle: Bundle, + context: Context, + ): EmbeddedPaymentElement.Configuration { + val merchantDisplayName = bundle.getString("merchantDisplayName").orEmpty() + val allowsDelayedPaymentMethods: Boolean = + if (bundle.containsKey("allowsDelayedPaymentMethods")) { + bundle.getBoolean("allowsDelayedPaymentMethods") + } else { + false + } + var defaultBillingDetails: PaymentSheet.BillingDetails? = null + val billingDetailsMap = bundle.getBundle("defaultBillingDetails") + if (billingDetailsMap != null) { + val addressBundle = billingDetailsMap.getBundle("address") + val address = + PaymentSheet.Address( + addressBundle?.getString("city"), + addressBundle?.getString("country"), + addressBundle?.getString("line1"), + addressBundle?.getString("line2"), + addressBundle?.getString("postalCode"), + addressBundle?.getString("state"), + ) + defaultBillingDetails = + PaymentSheet.BillingDetails( + address, + billingDetailsMap.getString("email"), + billingDetailsMap.getString("name"), + billingDetailsMap.getString("phone"), + ) + } + + val customerConfiguration = + try { + buildCustomerConfiguration(bundle) + } catch (error: PaymentSheetException) { + throw Error() + } + + val googlePayConfig = buildGooglePayConfig(bundle.getBundle("googlePay")) + val linkConfig = PaymentSheetFragment.buildLinkConfig(bundle.getBundle("link")) + val shippingDetails = + bundle.getBundle("defaultShippingDetails")?.let { + AddressSheetView.buildAddressDetails(it) + } + val appearance = + try { + buildPaymentSheetAppearance(bundle.getBundle("appearance"), context) + } catch (error: PaymentSheetAppearanceException) { + throw Error() + } + val billingConfigParams = bundle.getBundle("billingDetailsCollectionConfiguration") + val billingDetailsConfig = + PaymentSheet.BillingDetailsCollectionConfiguration( + name = mapToCollectionMode(billingConfigParams?.getString("name")), + phone = mapToCollectionMode(billingConfigParams?.getString("phone")), + email = mapToCollectionMode(billingConfigParams?.getString("email")), + address = mapToAddressCollectionMode(billingConfigParams?.getString("address")), + attachDefaultsToPaymentMethod = + if (billingConfigParams?.containsKey("attachDefaultsToPaymentMethod") == true) { + billingConfigParams.getBoolean("attachDefaultsToPaymentMethod") + } else { + false + }, + ) + val allowsRemovalOfLastSavedPaymentMethod = + if (bundle.containsKey("allowsRemovalOfLastSavedPaymentMethod")) { + bundle.getBoolean("allowsRemovalOfLastSavedPaymentMethod") + } else { + true + } + val primaryButtonLabel = bundle.getString("primaryButtonLabel") + val paymentMethodOrder = bundle.getStringArrayList("paymentMethodOrder") + + val formSheetAction = + bundle + .getBundle("formSheetAction") + ?.getString("type") + ?.let { type -> + when (type) { + "confirm" -> EmbeddedPaymentElement.FormSheetAction.Confirm + else -> EmbeddedPaymentElement.FormSheetAction.Continue + } + } + ?: EmbeddedPaymentElement.FormSheetAction.Continue + + val configurationBuilder = + EmbeddedPaymentElement.Configuration + .Builder(merchantDisplayName) + .formSheetAction(formSheetAction) + .allowsDelayedPaymentMethods(allowsDelayedPaymentMethods) + .defaultBillingDetails(defaultBillingDetails) + .customer(customerConfiguration) + .googlePay(googlePayConfig) + .link(linkConfig) + .appearance(appearance) + .shippingDetails(shippingDetails) + .billingDetailsCollectionConfiguration(billingDetailsConfig) + .preferredNetworks( + mapToPreferredNetworks( + bundle + .getIntegerArrayList("preferredNetworks") + ?.let { ArrayList(it) }, + ), + ).allowsRemovalOfLastSavedPaymentMethod(allowsRemovalOfLastSavedPaymentMethod) + .cardBrandAcceptance(mapToCardBrandAcceptance(bundle)) + .embeddedViewDisplaysMandateText( + if (bundle.containsKey("embeddedViewDisplaysMandateText")) { + bundle.getBoolean("embeddedViewDisplaysMandateText") + } else { + true + }, + ) + .customPaymentMethods( + parseCustomPaymentMethods( + bundle.getBundle("customPaymentMethodConfiguration") ?: Bundle(), + ), + ) + + primaryButtonLabel?.let { configurationBuilder.primaryButtonLabel(it) } + paymentMethodOrder?.let { configurationBuilder.paymentMethodOrder(it) } + + return configurationBuilder.build() + } + + internal fun parseRowSelectionBehavior(bundle: Bundle): RowSelectionBehaviorType { + val rowSelectionBehavior = + bundle + .getBundle("rowSelectionBehavior") + ?.getString("type") + ?.let { type -> + when (type) { + "immediateAction" -> RowSelectionBehaviorType.ImmediateAction + else -> RowSelectionBehaviorType.Default + } + } + ?: RowSelectionBehaviorType.Default + return rowSelectionBehavior + } + + internal fun parseIntentConfiguration(bundle: Bundle): PaymentSheet.IntentConfiguration { + val intentConfig = PaymentSheetFragment.buildIntentConfiguration(bundle) + return intentConfig ?: throw IllegalArgumentException("IntentConfiguration is null") + } + + override fun confirm(view: EmbeddedPaymentElementView) { + view.confirm() + } + + override fun clearPaymentOption(view: EmbeddedPaymentElementView) { + view.clearPaymentOption() + } +} + +/** + * Returns a List of Strings if the key exists and points to an array of strings, or null otherwise. + */ +fun ReadableMap.getStringArrayList(key: String): List? { + if (!hasKey(key) || getType(key) != ReadableType.Array) return null + val array: ReadableArray = getArray(key) ?: return null + + val result = mutableListOf() + for (i in 0 until array.size) { + // getString returns null if the element isn't actually a string + array.getString(i)?.let { result.add(it) } + } + return result +} + +/** + * Returns a List of Ints if the key exists and points to an array of numbers, or null otherwise. + */ +fun ReadableMap.getIntegerArrayList(key: String): List? { + if (!hasKey(key) || getType(key) != ReadableType.Array) return null + val array: ReadableArray = getArray(key) ?: return null + + val result = mutableListOf() + for (i in 0 until array.size) { + // getType check to skip non-number entries + if (array.getType(i) == ReadableType.Number) { + // if it's actually a float/double, this will truncate; adjust as needed + result.add(array.getInt(i)) + } + } + return result +} diff --git a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetAppearance.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetAppearance.kt index f49f74d75..91125e605 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetAppearance.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetAppearance.kt @@ -511,7 +511,9 @@ private fun dynamicColorFromParams( } // First check if it's a nested Bundle { "light": "#RRGGBB", "dark": "#RRGGBB" } - val colorBundle = params.getBundle(key) + val value = params.get(key) + val colorBundle = if (value is Bundle) value else null + if (colorBundle != null) { val isDark = ( diff --git a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetFragment.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetFragment.kt index bf3976a0a..d5962a61e 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetFragment.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/PaymentSheetFragment.kt @@ -615,6 +615,7 @@ class PaymentSheetFragment : paymentMethodTypes = intentConfigurationParams.getStringArrayList("paymentMethodTypes")?.toList() ?: emptyList(), + paymentMethodConfigurationId = intentConfigurationParams.getString("paymentMethodConfigurationId"), ) } diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift new file mode 100644 index 000000000..7d37bf3f9 --- /dev/null +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift @@ -0,0 +1,220 @@ +import Flutter +import Foundation +import UIKit +import Stripe +@_spi(EmbeddedPaymentElementPrivateBeta) import StripePaymentSheet + +private class FlutterEmbeddedPaymentElementContainerView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + clipsToBounds = true + } + + required init?(coder: NSCoder) { + fatalError() + } +} + +public class EmbeddedPaymentElementViewFactory: NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + public func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return EmbeddedPaymentElementPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + binaryMessenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} + +class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { + + private let embeddedView: FlutterEmbeddedPaymentElementContainerView + private let channel: FlutterMethodChannel + private var delegate: FlutterEmbeddedPaymentElementDelegate? + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + binaryMessenger messenger: FlutterBinaryMessenger + ) { + embeddedView = FlutterEmbeddedPaymentElementContainerView(frame: frame) + channel = FlutterMethodChannel( + name: "flutter.stripe/embedded_payment_element/\(viewId)", + binaryMessenger: messenger + ) + + super.init() + channel.setMethodCallHandler(handle) + + if let arguments = args as? [String: Any] { + initializeEmbeddedPaymentElement(arguments) + } + } + + private func initializeEmbeddedPaymentElement(_ arguments: [String: Any]) { + guard let intentConfiguration = arguments["intentConfiguration"] as? NSDictionary, + let configuration = arguments["configuration"] as? NSDictionary else { + channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": "Invalid configuration"]) + return + } + + let mutableIntentConfig = intentConfiguration.mutableCopy() as! NSMutableDictionary + mutableIntentConfig["confirmHandler"] = true + + StripeSdkImpl.shared.createEmbeddedPaymentElement( + intentConfig: mutableIntentConfig, + configuration: configuration, + resolve: { [weak self] result in + Task { @MainActor in + guard let self = self else { return } + + if let resultDict = result as? NSDictionary, + let error = resultDict["error"] as? NSDictionary { + let message = (error["localizedMessage"] as? String) + ?? (error["message"] as? String) + ?? "Unknown error" + var payload: [String: Any] = ["message": message, "details": error] + if let code = error["code"] { + payload["code"] = code + } + self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: payload) + return + } + + if let embeddedElement = StripeSdkImpl.shared.embeddedInstance { + self.attachEmbeddedView(embeddedElement) + } else { + self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": "Failed to create embedded payment element"]) + } + } + }, + reject: { [weak self] code, message, error in + guard let self = self else { return } + let errorMessage = message ?? error?.localizedDescription ?? "Unknown error" + var payload: [String: Any] = ["message": errorMessage] + if let error = error { + let errorDetails = Errors.createError(code ?? ErrorType.Failed, error) + if let details = errorDetails["error"] { + payload["details"] = details + } + payload["code"] = code ?? ErrorType.Failed + } + self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: payload) + } + ) + } + + @MainActor + private func attachEmbeddedView(_ embeddedElement: EmbeddedPaymentElement) { + delegate = FlutterEmbeddedPaymentElementDelegate(channel: channel) + embeddedElement.delegate = delegate + + let paymentElementView = embeddedElement.view + embeddedView.addSubview(paymentElementView) + paymentElementView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + paymentElementView.topAnchor.constraint(equalTo: embeddedView.topAnchor), + paymentElementView.leadingAnchor.constraint(equalTo: embeddedView.leadingAnchor), + paymentElementView.trailingAnchor.constraint(equalTo: embeddedView.trailingAnchor), + ]) + + if let viewController = embeddedView.window?.rootViewController { + embeddedElement.presentingViewController = viewController + } + + delegate?.embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: embeddedElement) + delegate?.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: embeddedElement) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "confirm": + StripeSdkImpl.shared.confirmEmbeddedPaymentElement( + resolve: { confirmResult in + result(confirmResult) + }, + reject: { code, message, error in + result(FlutterError(code: code ?? "Failed", message: message, details: error)) + } + ) + case "clearPaymentOption": + StripeSdkImpl.shared.clearEmbeddedPaymentOption() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + func view() -> UIView { + return embeddedView + } +} + +class FlutterEmbeddedPaymentElementDelegate: EmbeddedPaymentElementDelegate { + weak var channel: FlutterMethodChannel? + private var lastReportedHeight: CGFloat = 0 + private var isReportingHeight = false + + init(channel: FlutterMethodChannel) { + self.channel = channel + } + + func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + guard channel != nil else { return } + guard !isReportingHeight else { return } + + isReportingHeight = true + DispatchQueue.main.async { [weak self, weak embeddedPaymentElement] in + guard let self else { return } + defer { self.isReportingHeight = false } + guard + let embeddedPaymentElement, + let channel = self.channel, + embeddedPaymentElement.view.window != nil + else { return } + + let targetSize = embeddedPaymentElement.view.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize + ) + let newHeight = targetSize.height + + guard newHeight > 0 else { return } + guard abs(newHeight - self.lastReportedHeight) > 1.0 else { return } + + // Simulator was getting stuck because CA re-enters this callback; keep it guarded. + self.lastReportedHeight = newHeight + channel.invokeMethod("onHeightChanged", arguments: ["height": newHeight]) + } + } + + func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: EmbeddedPaymentElement) { + guard let channel = channel else { return } + + let displayDataDict = embeddedPaymentElement.paymentOption?.toDictionary() + channel.invokeMethod("onPaymentOptionChanged", arguments: ["paymentOption": displayDataDict as Any]) + } + + func embeddedPaymentElementWillPresent(embeddedPaymentElement: EmbeddedPaymentElement) { + if let viewController = embeddedPaymentElement.view.window?.rootViewController { + embeddedPaymentElement.presentingViewController = viewController + } + } +} diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/EmbeddedPaymentElementView.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/EmbeddedPaymentElementView.swift index 0d307d3b2..671a71fe2 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/EmbeddedPaymentElementView.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/EmbeddedPaymentElementView.swift @@ -1,33 +1,14 @@ -// -// EmbeddedPaymentElementView.swift -// stripe-react-native -// -// Created by Nick Porter on 4/16/25. -// - import Foundation import UIKit @_spi(EmbeddedPaymentElementPrivateBeta) import StripePaymentSheet -@objc(EmbeddedPaymentElementView) -class EmbeddedPaymentElementView: RCTViewManager { - - override static func requiresMainQueueSetup() -> Bool { - return true - } - - override func view() -> UIView! { - return EmbeddedPaymentElementContainerView(frame: .zero) - } -} - -@objc(EmbeddedPaymentElementContainerView) -public class EmbeddedPaymentElementContainerView: UIView, UIGestureRecognizerDelegate { +public class EmbeddedPaymentElementContainerView: UIView { private var embeddedPaymentElementView: UIView? override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear + clipsToBounds = true } required init?(coder: NSCoder) { @@ -37,7 +18,6 @@ public class EmbeddedPaymentElementContainerView: UIView, UIGestureRecognizerDel public override func didMoveToWindow() { super.didMoveToWindow() if window != nil { - // Only attach when we have a valid window attachPaymentElementIfAvailable() } } @@ -45,13 +25,11 @@ public class EmbeddedPaymentElementContainerView: UIView, UIGestureRecognizerDel public override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) if newWindow == nil { - // Remove the embedded view when moving away from window removePaymentElement() } } private func attachPaymentElementIfAvailable() { - // Don't attach if already attached guard embeddedPaymentElementView == nil, let embeddedElement = StripeSdkImpl.shared.embeddedInstance else { return @@ -69,8 +47,6 @@ public class EmbeddedPaymentElementContainerView: UIView, UIGestureRecognizerDel ]) self.embeddedPaymentElementView = paymentElementView - - // Update the presenting view controller whenever we attach updatePresentingViewController() } @@ -82,7 +58,9 @@ public class EmbeddedPaymentElementContainerView: UIView, UIGestureRecognizerDel private func updatePresentingViewController() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - StripeSdkImpl.shared.embeddedInstance?.presentingViewController = RCTPresentedViewController() + if let viewController = self.window?.rootViewController { + StripeSdkImpl.shared.embeddedInstance?.presentingViewController = viewController + } } } } diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift index 073d8e10f..39ed5d3ac 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift @@ -31,14 +31,32 @@ extension StripeSdkImpl { let intentConfig = buildIntentConfiguration( modeParams: modeParams, paymentMethodTypes: intentConfig["paymentMethodTypes"] as? [String], + paymentMethodConfigurationId: intentConfig["paymentMethodConfigurationId"] as? String, captureMethod: mapCaptureMethod(captureMethodString) ) - guard let configuration = buildEmbeddedPaymentElementConfiguration(params: configuration).configuration else { + let configResult = buildEmbeddedPaymentElementConfiguration(params: configuration) + if let error = configResult.error { + resolve(error) + return + } + guard let configuration = configResult.configuration else { resolve(Errors.createError(ErrorType.Failed, "Invalid configuration")) return } + if STPAPIClient.shared.publishableKey == nil || STPAPIClient.shared.publishableKey?.isEmpty == true { + let errorMsg = "Stripe publishableKey is not set" + resolve(Errors.createError(ErrorType.Failed, errorMsg)) + return + } + + if configuration.returnURL == nil || configuration.returnURL?.isEmpty == true { + let errorMsg = "returnURL is required for EmbeddedPaymentElement" + resolve(Errors.createError(ErrorType.Failed, errorMsg)) + return + } + Task { do { let embeddedPaymentElement = try await EmbeddedPaymentElement.create( @@ -49,19 +67,25 @@ extension StripeSdkImpl { embeddedPaymentElement.presentingViewController = RCTPresentedViewController() self.embeddedInstance = embeddedPaymentElement - // success: resolve promise resolve(nil) - // publish initial state embeddedInstanceDelegate.embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: embeddedPaymentElement) embeddedInstanceDelegate.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: embeddedPaymentElement) } catch { - // 1) still resolve the promise so JS hook can finish loading - resolve(nil) - - // 2) emit a loading‐failed event with the error message - let msg = error.localizedDescription - self.emitter?.emitEmbeddedPaymentElementLoadingFailed(["message": msg]) + let errorPayload = Errors.createError(ErrorType.Failed, error) + let errorDetails = errorPayload["error"] as? NSDictionary + let (message, code) = extractEmbeddedPaymentElementErrorInfo( + from: errorDetails, + fallbackMessage: error.localizedDescription, + fallbackCode: ErrorType.Failed + ) + dispatchEmbeddedPaymentElementLoadingFailed( + message: message, + code: code, + details: errorDetails + ) + resolve(errorPayload) + return } } @@ -71,8 +95,14 @@ extension StripeSdkImpl { public func confirmEmbeddedPaymentElement(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { DispatchQueue.main.async { [weak self] in - self?.embeddedInstance?.presentingViewController = RCTPresentedViewController() - self?.embeddedInstance?.confirm { result in + guard let embeddedInstance = self?.embeddedInstance else { + resolve([ + "status": "failed", + "error": "Embedded payment element not available" + ]) + return + } + embeddedInstance.confirm { result in switch result { case .completed: // Return an object with { status: 'completed' } @@ -108,6 +138,7 @@ extension StripeSdkImpl { let intentConfiguration = buildIntentConfiguration( modeParams: modeParams, paymentMethodTypes: intentConfig["paymentMethodTypes"] as? [String], + paymentMethodConfigurationId: intentConfig["paymentMethodConfigurationId"] as? String, captureMethod: mapCaptureMethod(captureMethodString) ) @@ -126,7 +157,18 @@ extension StripeSdkImpl { case .canceled: resolve(["status": "canceled"]) case .failed(let error): - self.emitter?.emitEmbeddedPaymentElementLoadingFailed(["message": error.localizedDescription]) + let errorPayload = Errors.createError(ErrorType.Failed, error) + let errorDetails = errorPayload["error"] as? NSDictionary + let (message, code) = extractEmbeddedPaymentElementErrorInfo( + from: errorDetails, + fallbackMessage: error.localizedDescription, + fallbackCode: ErrorType.Failed + ) + dispatchEmbeddedPaymentElementLoadingFailed( + message: message, + code: code, + details: errorDetails + ) // We don't resolve with an error b/c loading errors are handled via the embeddedPaymentElementLoadingFailed event resolve(nil) } @@ -140,20 +182,73 @@ extension StripeSdkImpl { } } + @nonobjc + private func extractEmbeddedPaymentElementErrorInfo( + from details: NSDictionary?, + fallbackMessage: String, + fallbackCode: String + ) -> (message: String, code: String) { + let message = (details?["localizedMessage"] as? String) + ?? (details?["message"] as? String) + ?? fallbackMessage + let code = (details?["code"] as? String) ?? fallbackCode + return (message, code) + } + + @nonobjc + private func dispatchEmbeddedPaymentElementLoadingFailed( + message: String, + code: String?, + details: NSDictionary? + ) { + guard self.emitter != nil else { return } + DispatchQueue.main.async { [weak self] in + guard let emitter = self?.emitter else { return } + var payload: [String: Any] = ["message": message] + if let code = code { + payload["code"] = code + } + if let details = details { + payload["details"] = details + } + emitter.emitEmbeddedPaymentElementLoadingFailed(payload) + } + } + } // MARK: EmbeddedPaymentElementDelegate class StripeSdkEmbeddedPaymentElementDelegate: EmbeddedPaymentElementDelegate { weak var sdkImpl: StripeSdkImpl? + // Simulator was getting stuck because CA re-enters this callback; keep it guarded. + private var isUpdatingHeight = false + private var lastReportedHeight: CGFloat = 0 init(sdkImpl: StripeSdkImpl) { self.sdkImpl = sdkImpl } func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { - let newHeight = embeddedPaymentElement.view.systemLayoutSizeFitting(CGSize(width: embeddedPaymentElement.view.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height - self.sdkImpl?.emitter?.emitEmbeddedPaymentElementDidUpdateHeight(["height": newHeight]) + guard !isUpdatingHeight else { return } + guard embeddedPaymentElement.view.window != nil else { return } + + isUpdatingHeight = true + DispatchQueue.main.async { [weak self, weak embeddedPaymentElement] in + defer { self?.isUpdatingHeight = false } + + guard let self, let embeddedPaymentElement, + embeddedPaymentElement.view.window != nil else { return } + + let newHeight = embeddedPaymentElement.view.systemLayoutSizeFitting( + CGSize(width: embeddedPaymentElement.view.bounds.width, + height: UIView.layoutFittingCompressedSize.height) + ).height + + guard newHeight > 0, abs(newHeight - self.lastReportedHeight) > 1 else { return } + self.lastReportedHeight = newHeight + self.sdkImpl?.emitter?.emitEmbeddedPaymentElementDidUpdateHeight(["height": newHeight]) + } } func embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: EmbeddedPaymentElement) { diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+PaymentSheet.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+PaymentSheet.swift index 81e688706..a200b5778 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+PaymentSheet.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+PaymentSheet.swift @@ -220,6 +220,7 @@ extension StripeSdkImpl { let intentConfig = buildIntentConfiguration( modeParams: modeParams, paymentMethodTypes: intentConfiguration["paymentMethodTypes"] as? [String], + paymentMethodConfigurationId: intentConfiguration["paymentMethodConfigurationId"] as? String, captureMethod: mapCaptureMethod(captureMethodString) ) @@ -292,6 +293,7 @@ extension StripeSdkImpl { func buildIntentConfiguration( modeParams: NSDictionary, paymentMethodTypes: [String]?, + paymentMethodConfigurationId: String?, captureMethod: PaymentSheet.IntentConfiguration.CaptureMethod ) -> PaymentSheet.IntentConfiguration { var mode: PaymentSheet.IntentConfiguration.Mode @@ -313,6 +315,7 @@ extension StripeSdkImpl { return PaymentSheet.IntentConfiguration.init( mode: mode, paymentMethodTypes: paymentMethodTypes, + paymentMethodConfigurationId: paymentMethodConfigurationId, confirmHandler: { paymentMethod, shouldSavePaymentMethod, intentCreationCallback in self.paymentSheetIntentCreationCallback = intentCreationCallback self.emitter?.emitOnConfirmHandlerCallback([ @@ -524,4 +527,3 @@ extension StripeSdkImpl { } } } - diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl.swift index 7febd580a..e4e17b09e 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl.swift @@ -107,6 +107,7 @@ public class StripeSdkImpl: NSObject, UIAdaptivePresentationControllerDelegate { STPAPIClient.shared.appInfo = STPAppInfo(name: name, partnerId: partnerId, version: version, url: url) self.merchantIdentifier = merchantIdentifier + StripeSdkImpl.shared.merchantIdentifier = merchantIdentifier resolve(NSNull()) } diff --git a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/StripePlugin.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/StripePlugin.swift index bb357cc3c..ecc0acf74 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/StripePlugin.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/StripePlugin.swift @@ -49,6 +49,7 @@ class StripePlugin: StripeSdkImpl, FlutterPlugin, ViewManagerDelegate { let instance = StripePlugin(channel: channel) instance.emitter = instance + StripeSdkImpl.shared.emitter = instance registrar.addMethodCallDelegate(instance, channel: channel) registrar.addApplicationDelegate(instance) @@ -72,6 +73,10 @@ class StripePlugin: StripeSdkImpl, FlutterPlugin, ViewManagerDelegate { let addressSheetFactory = AddressSheetViewFactory(messenger: registrar.messenger(), delegate: instance) registrar.register(addressSheetFactory, withId: "flutter.stripe/address_sheet") + // Embedded Payment Element + let embeddedPaymentElementFactory = EmbeddedPaymentElementViewFactory(messenger: registrar.messenger()) + registrar.register(embeddedPaymentElementFactory, withId: "flutter.stripe/embedded_payment_element") + } init(channel : FlutterMethodChannel) { @@ -760,7 +765,11 @@ extension StripePlugin { return } - intentCreationCallback(result: params, resolver: resolver(for: result), rejecter: rejecter(for: result)) + StripeSdkImpl.shared.intentCreationCallback( + result: params, + resolver: resolver(for: result), + rejecter: rejecter(for: result) + ) result(nil) } diff --git a/packages/stripe_platform_interface/lib/src/method_channel_stripe.dart b/packages/stripe_platform_interface/lib/src/method_channel_stripe.dart index 777506f3c..84f26b2d0 100644 --- a/packages/stripe_platform_interface/lib/src/method_channel_stripe.dart +++ b/packages/stripe_platform_interface/lib/src/method_channel_stripe.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:stripe_platform_interface/src/models/ach_params.dart'; import 'package:stripe_platform_interface/src/models/create_token_data.dart'; @@ -73,6 +74,9 @@ class MethodChannelStripe extends StripePlatform { _confirmHandler!( method, call.arguments['shouldSavePaymentMethod'] as bool, + (params) { + intentCreationCallback(params); + }, ); } else if (call.method == 'onCustomPaymentMethodConfirmHandlerCallback' && _confirmCustomPaymentMethodCallback != null) { @@ -693,6 +697,11 @@ class MethodChannelStripe extends StripePlatform { }); } + @override + void setConfirmHandler(ConfirmHandler? handler) { + _confirmHandler = handler; + } + @override Future canAddCardToWallet( CanAddCardToWalletParams params, diff --git a/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart b/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart index a109f623d..5f6f1ee87 100644 --- a/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart +++ b/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart @@ -140,6 +140,10 @@ abstract class IntentConfiguration with _$IntentConfiguration { /// If not set, the payment sheet will display all the payment methods enabled in your Stripe dashboard. List? paymentMethodTypes, + /// Configuration ID for the selected payment method configuration. + /// See https://stripe.com/docs/payments/multiple-payment-method-configs + String? paymentMethodConfigurationId, + /// Called when the customer confirms payment. Your implementation should create /// a payment intent or setupintent on your server and call the intent creation callback with its client secret or an error if one occurred. @JsonKey(includeFromJson: false, includeToJson: false) @@ -451,7 +455,6 @@ abstract class PaymentSheetPrimaryButtonThemeColors /// The text color of the primary button when in a success state. Supports both single color strings and light/dark color objects. @JsonKey(toJson: ColorKey.toJson, fromJson: ColorKey.fromJson) Color? successTextColor, - }) = _PaymentSheetPrimaryButtonThemeColors; factory PaymentSheetPrimaryButtonThemeColors.fromJson( @@ -606,7 +609,11 @@ enum IntentFutureUsage { } typedef ConfirmHandler = - void Function(PaymentMethod result, bool shouldSavePaymentMethod); + void Function( + PaymentMethod result, + bool shouldSavePaymentMethod, + void Function(IntentCreationCallbackParams) intentCreationCallback, + ); List _cardBrandListToJson(List? list) { if (list == null) { @@ -808,6 +815,7 @@ abstract class FlatConfig with _$FlatConfig { /// Describes the appearance of the floating button style payment method row @freezed abstract class FloatingConfig with _$FloatingConfig { + @JsonSerializable(explicitToJson: true) const factory FloatingConfig({ /// The spacing between payment method rows. double? spacing, @@ -820,6 +828,7 @@ abstract class FloatingConfig with _$FloatingConfig { /// Describes the appearance of the row in the Embedded Mobile Payment Element @freezed abstract class RowConfig with _$RowConfig { + @JsonSerializable(explicitToJson: true) const factory RowConfig({ /// The display style of the row. RowStyle? style, @@ -844,6 +853,7 @@ abstract class RowConfig with _$RowConfig { @freezed abstract class EmbeddedPaymentElementAppearance with _$EmbeddedPaymentElementAppearance { + @JsonSerializable(explicitToJson: true) const factory EmbeddedPaymentElementAppearance({RowConfig? row}) = _EmbeddedPaymentElementAppearance; diff --git a/packages/stripe_platform_interface/lib/src/models/payment_sheet.freezed.dart b/packages/stripe_platform_interface/lib/src/models/payment_sheet.freezed.dart index 1227cd487..2cc36bc00 100644 --- a/packages/stripe_platform_interface/lib/src/models/payment_sheet.freezed.dart +++ b/packages/stripe_platform_interface/lib/src/models/payment_sheet.freezed.dart @@ -715,7 +715,9 @@ mixin _$IntentConfiguration { IntentMode get mode;/// The list of payment method types that the customer can use in the payment sheet. /// /// If not set, the payment sheet will display all the payment methods enabled in your Stripe dashboard. - List? get paymentMethodTypes;/// Called when the customer confirms payment. Your implementation should create + List? get paymentMethodTypes;/// Configuration ID for the selected payment method configuration. +/// See https://stripe.com/docs/payments/multiple-payment-method-configs + String? get paymentMethodConfigurationId;/// Called when the customer confirms payment. Your implementation should create /// a payment intent or setupintent on your server and call the intent creation callback with its client secret or an error if one occurred. @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? get confirmHandler; /// Create a copy of IntentConfiguration @@ -730,16 +732,16 @@ $IntentConfigurationCopyWith get copyWith => _$IntentConfig @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is IntentConfiguration&&(identical(other.mode, mode) || other.mode == mode)&&const DeepCollectionEquality().equals(other.paymentMethodTypes, paymentMethodTypes)&&(identical(other.confirmHandler, confirmHandler) || other.confirmHandler == confirmHandler)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is IntentConfiguration&&(identical(other.mode, mode) || other.mode == mode)&&const DeepCollectionEquality().equals(other.paymentMethodTypes, paymentMethodTypes)&&(identical(other.paymentMethodConfigurationId, paymentMethodConfigurationId) || other.paymentMethodConfigurationId == paymentMethodConfigurationId)&&(identical(other.confirmHandler, confirmHandler) || other.confirmHandler == confirmHandler)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,mode,const DeepCollectionEquality().hash(paymentMethodTypes),confirmHandler); +int get hashCode => Object.hash(runtimeType,mode,const DeepCollectionEquality().hash(paymentMethodTypes),paymentMethodConfigurationId,confirmHandler); @override String toString() { - return 'IntentConfiguration(mode: $mode, paymentMethodTypes: $paymentMethodTypes, confirmHandler: $confirmHandler)'; + return 'IntentConfiguration(mode: $mode, paymentMethodTypes: $paymentMethodTypes, paymentMethodConfigurationId: $paymentMethodConfigurationId, confirmHandler: $confirmHandler)'; } @@ -750,7 +752,7 @@ abstract mixin class $IntentConfigurationCopyWith<$Res> { factory $IntentConfigurationCopyWith(IntentConfiguration value, $Res Function(IntentConfiguration) _then) = _$IntentConfigurationCopyWithImpl; @useResult $Res call({ - IntentMode mode, List? paymentMethodTypes,@JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler + IntentMode mode, List? paymentMethodTypes, String? paymentMethodConfigurationId,@JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler }); @@ -767,11 +769,12 @@ class _$IntentConfigurationCopyWithImpl<$Res> /// Create a copy of IntentConfiguration /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? mode = null,Object? paymentMethodTypes = freezed,Object? confirmHandler = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? mode = null,Object? paymentMethodTypes = freezed,Object? paymentMethodConfigurationId = freezed,Object? confirmHandler = freezed,}) { return _then(_self.copyWith( mode: null == mode ? _self.mode : mode // ignore: cast_nullable_to_non_nullable as IntentMode,paymentMethodTypes: freezed == paymentMethodTypes ? _self.paymentMethodTypes : paymentMethodTypes // ignore: cast_nullable_to_non_nullable -as List?,confirmHandler: freezed == confirmHandler ? _self.confirmHandler : confirmHandler // ignore: cast_nullable_to_non_nullable +as List?,paymentMethodConfigurationId: freezed == paymentMethodConfigurationId ? _self.paymentMethodConfigurationId : paymentMethodConfigurationId // ignore: cast_nullable_to_non_nullable +as String?,confirmHandler: freezed == confirmHandler ? _self.confirmHandler : confirmHandler // ignore: cast_nullable_to_non_nullable as ConfirmHandler?, )); } @@ -866,10 +869,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( IntentMode mode, List? paymentMethodTypes, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( IntentMode mode, List? paymentMethodTypes, String? paymentMethodConfigurationId, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _IntentConfiguration() when $default != null: -return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _: +return $default(_that.mode,_that.paymentMethodTypes,_that.paymentMethodConfigurationId,_that.confirmHandler);case _: return orElse(); } @@ -887,10 +890,10 @@ return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _ /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( IntentMode mode, List? paymentMethodTypes, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( IntentMode mode, List? paymentMethodTypes, String? paymentMethodConfigurationId, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler) $default,) {final _that = this; switch (_that) { case _IntentConfiguration(): -return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _: +return $default(_that.mode,_that.paymentMethodTypes,_that.paymentMethodConfigurationId,_that.confirmHandler);case _: throw StateError('Unexpected subclass'); } @@ -907,10 +910,10 @@ return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _ /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( IntentMode mode, List? paymentMethodTypes, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( IntentMode mode, List? paymentMethodTypes, String? paymentMethodConfigurationId, @JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler)? $default,) {final _that = this; switch (_that) { case _IntentConfiguration() when $default != null: -return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _: +return $default(_that.mode,_that.paymentMethodTypes,_that.paymentMethodConfigurationId,_that.confirmHandler);case _: return null; } @@ -922,7 +925,7 @@ return $default(_that.mode,_that.paymentMethodTypes,_that.confirmHandler);case _ @JsonSerializable(explicitToJson: true) class _IntentConfiguration implements IntentConfiguration { - const _IntentConfiguration({required this.mode, final List? paymentMethodTypes, @JsonKey(includeFromJson: false, includeToJson: false) this.confirmHandler}): _paymentMethodTypes = paymentMethodTypes; + const _IntentConfiguration({required this.mode, final List? paymentMethodTypes, this.paymentMethodConfigurationId, @JsonKey(includeFromJson: false, includeToJson: false) this.confirmHandler}): _paymentMethodTypes = paymentMethodTypes; factory _IntentConfiguration.fromJson(Map json) => _$IntentConfigurationFromJson(json); /// Data related to the future payment intent @@ -942,6 +945,9 @@ class _IntentConfiguration implements IntentConfiguration { return EqualUnmodifiableListView(value); } +/// Configuration ID for the selected payment method configuration. +/// See https://stripe.com/docs/payments/multiple-payment-method-configs +@override final String? paymentMethodConfigurationId; /// Called when the customer confirms payment. Your implementation should create /// a payment intent or setupintent on your server and call the intent creation callback with its client secret or an error if one occurred. @override@JsonKey(includeFromJson: false, includeToJson: false) final ConfirmHandler? confirmHandler; @@ -959,16 +965,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _IntentConfiguration&&(identical(other.mode, mode) || other.mode == mode)&&const DeepCollectionEquality().equals(other._paymentMethodTypes, _paymentMethodTypes)&&(identical(other.confirmHandler, confirmHandler) || other.confirmHandler == confirmHandler)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _IntentConfiguration&&(identical(other.mode, mode) || other.mode == mode)&&const DeepCollectionEquality().equals(other._paymentMethodTypes, _paymentMethodTypes)&&(identical(other.paymentMethodConfigurationId, paymentMethodConfigurationId) || other.paymentMethodConfigurationId == paymentMethodConfigurationId)&&(identical(other.confirmHandler, confirmHandler) || other.confirmHandler == confirmHandler)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,mode,const DeepCollectionEquality().hash(_paymentMethodTypes),confirmHandler); +int get hashCode => Object.hash(runtimeType,mode,const DeepCollectionEquality().hash(_paymentMethodTypes),paymentMethodConfigurationId,confirmHandler); @override String toString() { - return 'IntentConfiguration(mode: $mode, paymentMethodTypes: $paymentMethodTypes, confirmHandler: $confirmHandler)'; + return 'IntentConfiguration(mode: $mode, paymentMethodTypes: $paymentMethodTypes, paymentMethodConfigurationId: $paymentMethodConfigurationId, confirmHandler: $confirmHandler)'; } @@ -979,7 +985,7 @@ abstract mixin class _$IntentConfigurationCopyWith<$Res> implements $IntentConfi factory _$IntentConfigurationCopyWith(_IntentConfiguration value, $Res Function(_IntentConfiguration) _then) = __$IntentConfigurationCopyWithImpl; @override @useResult $Res call({ - IntentMode mode, List? paymentMethodTypes,@JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler + IntentMode mode, List? paymentMethodTypes, String? paymentMethodConfigurationId,@JsonKey(includeFromJson: false, includeToJson: false) ConfirmHandler? confirmHandler }); @@ -996,11 +1002,12 @@ class __$IntentConfigurationCopyWithImpl<$Res> /// Create a copy of IntentConfiguration /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? mode = null,Object? paymentMethodTypes = freezed,Object? confirmHandler = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? mode = null,Object? paymentMethodTypes = freezed,Object? paymentMethodConfigurationId = freezed,Object? confirmHandler = freezed,}) { return _then(_IntentConfiguration( mode: null == mode ? _self.mode : mode // ignore: cast_nullable_to_non_nullable as IntentMode,paymentMethodTypes: freezed == paymentMethodTypes ? _self._paymentMethodTypes : paymentMethodTypes // ignore: cast_nullable_to_non_nullable -as List?,confirmHandler: freezed == confirmHandler ? _self.confirmHandler : confirmHandler // ignore: cast_nullable_to_non_nullable +as List?,paymentMethodConfigurationId: freezed == paymentMethodConfigurationId ? _self.paymentMethodConfigurationId : paymentMethodConfigurationId // ignore: cast_nullable_to_non_nullable +as String?,confirmHandler: freezed == confirmHandler ? _self.confirmHandler : confirmHandler // ignore: cast_nullable_to_non_nullable as ConfirmHandler?, )); } @@ -8393,8 +8400,8 @@ return $default(_that.spacing);case _: } /// @nodoc -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class _FloatingConfig implements FloatingConfig { const _FloatingConfig({this.spacing}); factory _FloatingConfig.fromJson(Map json) => _$FloatingConfigFromJson(json); @@ -8690,8 +8697,8 @@ return $default(_that.style,_that.additionalInsets,_that.flat,_that.floating);ca } /// @nodoc -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class _RowConfig implements RowConfig { const _RowConfig({this.style, this.additionalInsets, this.flat, this.floating}); factory _RowConfig.fromJson(Map json) => _$RowConfigFromJson(json); @@ -9001,8 +9008,8 @@ return $default(_that.row);case _: } /// @nodoc -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class _EmbeddedPaymentElementAppearance implements EmbeddedPaymentElementAppearance { const _EmbeddedPaymentElementAppearance({this.row}); factory _EmbeddedPaymentElementAppearance.fromJson(Map json) => _$EmbeddedPaymentElementAppearanceFromJson(json); diff --git a/packages/stripe_platform_interface/lib/src/models/payment_sheet.g.dart b/packages/stripe_platform_interface/lib/src/models/payment_sheet.g.dart index e457f1fbb..5c723830c 100644 --- a/packages/stripe_platform_interface/lib/src/models/payment_sheet.g.dart +++ b/packages/stripe_platform_interface/lib/src/models/payment_sheet.g.dart @@ -135,6 +135,8 @@ _IntentConfiguration _$IntentConfigurationFromJson(Map json) => paymentMethodTypes: (json['paymentMethodTypes'] as List?) ?.map((e) => e as String) .toList(), + paymentMethodConfigurationId: + json['paymentMethodConfigurationId'] as String?, ); Map _$IntentConfigurationToJson( @@ -142,6 +144,7 @@ Map _$IntentConfigurationToJson( ) => { 'mode': instance.mode.toJson(), 'paymentMethodTypes': instance.paymentMethodTypes, + 'paymentMethodConfigurationId': instance.paymentMethodConfigurationId, }; _PaymentMode _$PaymentModeFromJson(Map json) => _PaymentMode( @@ -721,8 +724,8 @@ Map _$RowConfigToJson(_RowConfig instance) => { 'style': _$RowStyleEnumMap[instance.style], 'additionalInsets': instance.additionalInsets, - 'flat': instance.flat, - 'floating': instance.floating, + 'flat': instance.flat?.toJson(), + 'floating': instance.floating?.toJson(), }; const _$RowStyleEnumMap = { @@ -742,7 +745,7 @@ _EmbeddedPaymentElementAppearance _$EmbeddedPaymentElementAppearanceFromJson( Map _$EmbeddedPaymentElementAppearanceToJson( _EmbeddedPaymentElementAppearance instance, -) => {'row': instance.row}; +) => {'row': instance.row?.toJson()}; _CustomPaymentMethod _$CustomPaymentMethodFromJson(Map json) => _CustomPaymentMethod( diff --git a/packages/stripe_platform_interface/lib/src/stripe_platform_interface.dart b/packages/stripe_platform_interface/lib/src/stripe_platform_interface.dart index 0853e6ef7..b82502038 100644 --- a/packages/stripe_platform_interface/lib/src/stripe_platform_interface.dart +++ b/packages/stripe_platform_interface/lib/src/stripe_platform_interface.dart @@ -191,6 +191,9 @@ abstract class StripePlatform extends PlatformInterface { /// or not successfull when using a defferred payment method. Future intentCreationCallback(IntentCreationCallbackParams params); + /// Set the confirm handler for embedded payment elements + void setConfirmHandler(ConfirmHandler? handler); + Widget buildCard({ Key? key, required CardEditController controller,