From edb793c55b3f6781a3b95c8b05eaa5e7500dd822 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 02:52:54 +0545 Subject: [PATCH 01/17] feat(flutter): return payment result from EmbeddedPaymentElement.confirm() Changes confirm() method to return payment result instead of void, allowing Flutter apps to detect payment failures without relying solely on callbacks. - Modified EmbeddedPaymentElementController.confirm() return type - Updated embedded_payment_element.dart to capture method channel result - iOS implementation complete with result dictionary - Android implementation incomplete (help wanted) Fixes detection of payment failures (insufficient funds, card declined, etc.) Follows React Native SDK pattern where confirm() returns result status. --- packages/stripe/lib/flutter_stripe.dart | 2 + .../lib/src/model/apple_pay_button.dart | 7 +- packages/stripe/lib/src/stripe.dart | 113 +++--- .../stripe/lib/src/widgets/adress_sheet.dart | 31 +- .../lib/src/widgets/apple_pay_button.dart | 30 +- .../lib/src/widgets/aubecs_debit_form.dart | 19 +- .../lib/src/widgets/card_form_field.dart | 115 +++--- .../src/widgets/embedded_payment_element.dart | 305 +++++++++++++++ .../embedded_payment_element_controller.dart | 50 +++ packages/stripe/pubspec.yaml | 9 +- .../com/flutter/stripe/StripeAndroidPlugin.kt | 8 + ...peSdkEmbeddedPaymentElementPlatformView.kt | 81 ++++ ...beddedPaymentElementPlatformViewFactory.kt | 36 ++ .../EmbeddedPaymentElementView.kt | 368 ++++++++++++++++++ .../EmbeddedPaymentElementViewManager.kt | 286 ++++++++++++++ .../EmbeddedPaymentElementFactory.swift | 188 +++++++++ .../EmbeddedPaymentElementView.swift | 32 +- .../Stripe Sdk/StripeSdkImpl+Embedded.swift | 44 ++- .../StripeSdkImpl+PaymentSheet.swift | 1 - .../stripe_ios/Stripe Sdk/StripeSdkImpl.swift | 1 + .../Sources/stripe_ios/StripePlugin.swift | 11 +- .../lib/src/method_channel_stripe.dart | 9 + .../lib/src/models/payment_sheet.dart | 10 +- .../lib/src/models/payment_sheet.freezed.dart | 6 +- .../lib/src/models/payment_sheet.g.dart | 6 +- .../lib/src/stripe_platform_interface.dart | 3 + 26 files changed, 1577 insertions(+), 194 deletions(-) create mode 100644 packages/stripe/lib/src/widgets/embedded_payment_element.dart create mode 100644 packages/stripe/lib/src/widgets/embedded_payment_element_controller.dart create mode 100644 packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt create mode 100644 packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformViewFactory.kt create mode 100644 packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt create mode 100644 packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt create mode 100644 packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift 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..8cd0cdd56 --- /dev/null +++ b/packages/stripe/lib/src/widgets/embedded_payment_element.dart @@ -0,0 +1,305 @@ +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(String message); + +/// 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(); + +/// 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; + setState(() { + _currentHeight = height; + }); + widget.onHeightChanged?.call(height); + } + break; + case 'embeddedPaymentElementLoadingFailed': + final arguments = call.arguments as Map?; + final message = arguments?['message'] as String? ?? 'Unknown error'; + widget.onLoadingFailed?.call(message); + 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'); + } + } + + @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 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/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..255eff02e --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt @@ -0,0 +1,81 @@ +package com.flutter.stripe + +import android.content.Context +import android.view.View +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.ThemedReactContext +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) + + creationParams?.convertToReadables()?.forEach { entry -> + when (entry.key) { + "configuration" -> { + viewManager.setConfiguration(embeddedView, entry.value) + } + "intentConfiguration" -> { + viewManager.setIntentConfiguration(embeddedView, entry.value) + } + } + } + } + + override fun getView(): View { + return embeddedView + } + + override fun dispose() { + viewManager.onDropViewInstance(embeddedView) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "confirm" -> { + viewManager.confirm(embeddedView) + result.success(null) + } + "clearPaymentOption" -> { + viewManager.clearPaymentOption(embeddedView) + result.success(null) + } + "updateConfiguration" -> { + val config = call.arguments.convertToReadable() + viewManager.setConfiguration(embeddedView, config) + result.success(null) + } + "updateIntentConfiguration" -> { + val intentConfig = call.arguments.convertToReadable() + viewManager.setIntentConfiguration(embeddedView, intentConfig) + 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/reactnativestripesdk/EmbeddedPaymentElementView.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt new file mode 100644 index 000000000..ab9f1b72f --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt @@ -0,0 +1,368 @@ +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.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.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, +} + +@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) + + 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 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 { + 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 -> { + // send the error back to JS + val err = result.error + val msg = err.localizedMessage ?: err.toString() + // build a RN map + val payload = + Arguments.createMap().apply { + putString("message", msg) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed(payload) + } + } + } + + is Event.Confirm -> { + embedded.confirm() + } + is Event.ClearPaymentOption -> { + embedded.clearPaymentOption() + } + } + } + } + + LaunchedEffect(embedded) { + embedded.paymentOption.collect { opt -> + val optMap = opt?.toWritableMap() + 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) { + 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)) +} 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..4297ebac4 --- /dev/null +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt @@ -0,0 +1,286 @@ +package com.reactnativestripesdk + +import android.annotation.SuppressLint +import android.content.Context +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 rowSelectionBehaviorType = parseRowSelectionBehavior(readableMap) + view.rowSelectionBehaviorType.value = rowSelectionBehaviorType + + val elementConfig = parseElementConfiguration(readableMap, view.context) + view.latestElementConfig = elementConfig + // if intentConfig is already set, configure immediately: + 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 intentConfig = parseIntentConfiguration(readableMap) + view.latestIntentConfig = intentConfig + view.latestElementConfig?.let { elemCfg -> + view.configure(elemCfg, intentConfig) + } + } + + @SuppressLint("RestrictedApi") + @OptIn( + ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi::class, + ExperimentalCustomPaymentMethodsApi::class, + ) + private fun parseElementConfiguration( + map: ReadableMap, + context: Context, + ): EmbeddedPaymentElement.Configuration { + val merchantDisplayName = map.getString("merchantDisplayName").orEmpty() + val allowsDelayedPaymentMethods: Boolean = + if (map.hasKey("allowsDelayedPaymentMethods") && + map.getType("allowsDelayedPaymentMethods") == ReadableType.Boolean + ) { + map.getBoolean("allowsDelayedPaymentMethods") + } else { + false // default + } + var defaultBillingDetails: PaymentSheet.BillingDetails? = null + val billingDetailsMap = map.getMap("defaultBillingDetails") + if (billingDetailsMap != null) { + val addressBundle = billingDetailsMap.getMap("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(toBundleObject(map)) + } catch (error: PaymentSheetException) { + throw Error() // TODO handle error + } + + val googlePayConfig = buildGooglePayConfig(toBundleObject(map.getMap("googlePay"))) + val linkConfig = PaymentSheetFragment.buildLinkConfig(toBundleObject(map.getMap("link"))) + val shippingDetails = + map.getMap("defaultShippingDetails")?.let { + AddressSheetView.buildAddressDetails(it) + } + val appearance = + try { + buildPaymentSheetAppearance(toBundleObject(map.getMap("appearance")), context) + } catch (error: PaymentSheetAppearanceException) { + throw Error() // TODO handle error + } + val billingConfigParams = map.getMap("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 = + billingConfigParams?.getBooleanOr("attachDefaultsToPaymentMethod", false) ?: false, + ) + val allowsRemovalOfLastSavedPaymentMethod = + if (map.hasKey("allowsRemovalOfLastSavedPaymentMethod")) { + map.getBoolean("allowsRemovalOfLastSavedPaymentMethod") + } else { + true + } + val primaryButtonLabel = map.getString("primaryButtonLabel") + val paymentMethodOrder = map.getStringArrayList("paymentMethodOrder") + + val formSheetAction = + map + .getMap("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 ?: false) + .defaultBillingDetails(defaultBillingDetails) + .customer(customerConfiguration) + .googlePay(googlePayConfig) + .link(linkConfig) + .appearance(appearance) + .shippingDetails(shippingDetails) + .billingDetailsCollectionConfiguration(billingDetailsConfig) + .preferredNetworks( + mapToPreferredNetworks( + map + .getIntegerArrayList("preferredNetworks") + ?.let { ArrayList(it) }, + ), + ).allowsRemovalOfLastSavedPaymentMethod(allowsRemovalOfLastSavedPaymentMethod) + .cardBrandAcceptance(mapToCardBrandAcceptance(toBundleObject(map))) + .embeddedViewDisplaysMandateText( + if (map.hasKey("embeddedViewDisplaysMandateText") && + map.getType("embeddedViewDisplaysMandateText") == ReadableType.Boolean + ) { + map.getBoolean("embeddedViewDisplaysMandateText") + } else { + true // default value + }, + ) + // Serialize original ReadableMap because toBundleObject cannot keep arrays of objects + .customPaymentMethods( + parseCustomPaymentMethods( + toBundleObject(map.getMap("customPaymentMethodConfiguration")).apply { + map.getMap("customPaymentMethodConfiguration")?.let { readable -> + putSerializable("customPaymentMethodConfigurationReadableMap", readable.toHashMap()) + } + }, + ), + ) + + primaryButtonLabel?.let { configurationBuilder.primaryButtonLabel(it) } + paymentMethodOrder?.let { configurationBuilder.paymentMethodOrder(it) } + + return configurationBuilder.build() + } + + private fun parseRowSelectionBehavior(map: ReadableMap): RowSelectionBehaviorType { + val rowSelectionBehavior = + map + .getMap("rowSelectionBehavior") + ?.getString("type") + ?.let { type -> + when (type) { + "immediateAction" -> RowSelectionBehaviorType.ImmediateAction + else -> RowSelectionBehaviorType.Default + } + } + ?: RowSelectionBehaviorType.Default + return rowSelectionBehavior + } + + private fun parseIntentConfiguration(map: ReadableMap): PaymentSheet.IntentConfiguration { + val intentConfig = PaymentSheetFragment.buildIntentConfiguration(toBundleObject(map)) + 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_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..62cb04061 --- /dev/null +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift @@ -0,0 +1,188 @@ +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["message"] as? String { + self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": message]) + 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" + self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": errorMessage]) + } + ) + } + + @MainActor + private func attachEmbeddedView(_ embeddedElement: EmbeddedPaymentElement) { + delegate = FlutterEmbeddedPaymentElementDelegate(channel: channel, embeddedView: embeddedView) + 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? + weak var embeddedView: UIView? + + init(channel: FlutterMethodChannel, embeddedView: UIView) { + self.channel = channel + self.embeddedView = embeddedView + } + + func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { + guard let channel = channel else { return } + + let newHeight = embeddedPaymentElement.view.systemLayoutSizeFitting( + CGSize(width: embeddedPaymentElement.view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + ).height + + 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 = embeddedView?.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..170b45399 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 @@ -34,11 +34,28 @@ extension StripeSdkImpl { 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 +66,20 @@ 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]) + + if self.emitter != nil { + self.emitter?.emitEmbeddedPaymentElementLoadingFailed(["message": msg]) + } else { + //TODO HANDLE emitter nil + } + + resolve(nil) } } @@ -71,8 +89,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' } 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..c3435da83 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 @@ -524,4 +524,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..2a737b252 100644 --- a/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart +++ b/packages/stripe_platform_interface/lib/src/models/payment_sheet.dart @@ -451,7 +451,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 +605,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 +811,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 +824,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 +849,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..0517c3fc9 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 @@ -8393,8 +8393,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 +8690,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 +9001,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..fdbd9b0d2 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 @@ -721,8 +721,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 +742,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, From 90f3750cd73ef1e509eecfb9b08cf5edb3421038 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 03:22:29 +0545 Subject: [PATCH 02/17] feat(android): return payment result from confirm() method channel - Added onConfirmResult callback property to EmbeddedPaymentElementView - Modified resultCallback to invoke Flutter method channel callback - Updated platform view to set callback before calling confirm() - Callback returns Map with status and optional error message - Matches iOS implementation for consistent cross-platform behavior --- ...StripeSdkEmbeddedPaymentElementPlatformView.kt | 5 ++++- .../EmbeddedPaymentElementView.kt | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 index 255eff02e..3b456242f 100644 --- 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 @@ -49,8 +49,11 @@ class StripeSdkEmbeddedPaymentElementPlatformView( override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "confirm" -> { + embeddedView.onConfirmResult = { resultMap -> + result.success(resultMap) + embeddedView.onConfirmResult = null + } viewManager.confirm(embeddedView) - result.success(null) } "clearPaymentOption" -> { viewManager.clearPaymentOption(embeddedView) 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 index ab9f1b72f..19381b71c 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt @@ -64,6 +64,8 @@ class EmbeddedPaymentElementView( val rowSelectionBehaviorType = mutableStateOf(null) + var onConfirmResult: ((Map) -> Unit)? = null + private val reactContext get() = context as ThemedReactContext private val events = Channel(Channel.UNLIMITED) @@ -217,6 +219,19 @@ class EmbeddedPaymentElementView( } } } + + // Call Flutter method channel result callback + onConfirmResult?.invoke( + 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")) + } + ) + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementFormSheetConfirmComplete(map) }, ).confirmCustomPaymentMethodCallback(confirmCustomPaymentMethodCallback) From 5e8ce5efe6c9b3c2716c736cd56bac2b8ce6c9b0 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 03:42:23 +0545 Subject: [PATCH 03/17] fix(android): remove unused updateConfiguration methods causing type errors - Removed updateConfiguration and updateIntentConfiguration from method channel handler - These methods were unused and causing Dynamic type mismatch compile errors - Configuration is set once during initialization and doesn't need updates --- .../StripeSdkEmbeddedPaymentElementPlatformView.kt | 10 ---------- 1 file changed, 10 deletions(-) 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 index 3b456242f..d1993ab70 100644 --- 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 @@ -59,16 +59,6 @@ class StripeSdkEmbeddedPaymentElementPlatformView( viewManager.clearPaymentOption(embeddedView) result.success(null) } - "updateConfiguration" -> { - val config = call.arguments.convertToReadable() - viewManager.setConfiguration(embeddedView, config) - result.success(null) - } - "updateIntentConfiguration" -> { - val intentConfig = call.arguments.convertToReadable() - viewManager.setIntentConfiguration(embeddedView, intentConfig) - result.success(null) - } else -> { result.notImplemented() } From 15915bcb9e0df6114246487934ed79d5d8207a6c Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 03:45:02 +0545 Subject: [PATCH 04/17] fix(android): add missing Jetpack Compose dependencies and update Kotlin - Added Jetpack Compose BOM and core dependencies (ui, foundation, runtime) - Updated Kotlin from 1.8.0 to 1.9.0 for Compose compatibility - Added Compose build features and compiler extension version - Fixes 'Unresolved reference' errors for Box, requiredHeight, foundation - Required for EmbeddedPaymentElement which uses Compose UI --- packages/stripe_android/android/build.gradle | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/stripe_android/android/build.gradle b/packages/stripe_android/android/build.gradle index 1daa0a496..a617ca619 100644 --- a/packages/stripe_android/android/build.gradle +++ b/packages/stripe_android/android/build.gradle @@ -2,8 +2,9 @@ group 'com.flutter.stripe' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.8.0' + ext.kotlin_version = '1.9.0' ext.stripe_version = '21.26.+' + ext.compose_version = '1.5.1' repositories { google() @@ -39,6 +40,14 @@ android { jvmTarget = '17' } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = '1.5.1' + } + sourceSets { main.java.srcDirs += 'src/main/kotlin' } @@ -59,6 +68,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" From f9d132cef85056021135f137f0971bfef33b9611 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 11:54:31 +0545 Subject: [PATCH 05/17] feat(android): mirror React Native SDK Kotlin 2.0 detection - Read kotlinVersion from rootProject.ext with fallback - Check kotlinMajor version to conditionally load compose plugin - Fixes Kotlin 2.0 compose compiler requirement --- packages/stripe_android/android/build.gradle | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/stripe_android/android/build.gradle b/packages/stripe_android/android/build.gradle index a617ca619..9bd6e715a 100644 --- a/packages/stripe_android/android/build.gradle +++ b/packages/stripe_android/android/build.gradle @@ -2,10 +2,16 @@ group 'com.flutter.stripe' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.9.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() mavenCentral() @@ -14,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" + } } } @@ -24,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' From 496ce52a09feb37fcd773b7b5afb8ae3ea97a11f Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 12:06:55 +0545 Subject: [PATCH 06/17] fix(android): fix Kotlin type mismatches and array size calls - Cast entry.value to Dynamic for ViewManager methods - Change array.size() to array.size (property not function) - Fixes Kotlin 2.0 compilation errors --- .../stripe/StripeSdkEmbeddedPaymentElementPlatformView.kt | 4 ++-- .../reactnativestripesdk/EmbeddedPaymentElementViewManager.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index d1993ab70..c95f36a76 100644 --- 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 @@ -29,10 +29,10 @@ class StripeSdkEmbeddedPaymentElementPlatformView( creationParams?.convertToReadables()?.forEach { entry -> when (entry.key) { "configuration" -> { - viewManager.setConfiguration(embeddedView, entry.value) + entry.value?.let { viewManager.setConfiguration(embeddedView, it as com.facebook.react.bridge.Dynamic) } } "intentConfiguration" -> { - viewManager.setIntentConfiguration(embeddedView, entry.value) + entry.value?.let { viewManager.setIntentConfiguration(embeddedView, it as com.facebook.react.bridge.Dynamic) } } } } 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 index 4297ebac4..9c9d27fba 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt @@ -260,7 +260,7 @@ fun ReadableMap.getStringArrayList(key: String): List? { val array: ReadableArray = getArray(key) ?: return null val result = mutableListOf() - for (i in 0 until array.size()) { + 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) } } @@ -275,7 +275,7 @@ fun ReadableMap.getIntegerArrayList(key: String): List? { val array: ReadableArray = getArray(key) ?: return null val result = mutableListOf() - for (i in 0 until array.size()) { + 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 From 76b86e443e76ff1d3295880acabdb16c1f25d328 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 12:44:17 +0545 Subject: [PATCH 07/17] Fix Android crash when using embedded payment element Android would crash when intentConfiguration was provided. Fixed by updating how payment data is passed to native code. --- ...peSdkEmbeddedPaymentElementPlatformView.kt | 32 +++++-- .../stripe/StripeSdkModuleExtensions.kt | 46 ++++++++++ .../EmbeddedPaymentElementViewManager.kt | 87 +++++++++---------- 3 files changed, 113 insertions(+), 52 deletions(-) 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 index c95f36a76..eb46f1190 100644 --- 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 @@ -26,13 +26,31 @@ class StripeSdkEmbeddedPaymentElementPlatformView( init { channel.setMethodCallHandler(this) - creationParams?.convertToReadables()?.forEach { entry -> - when (entry.key) { - "configuration" -> { - entry.value?.let { viewManager.setConfiguration(embeddedView, it as com.facebook.react.bridge.Dynamic) } - } - "intentConfiguration" -> { - entry.value?.let { viewManager.setIntentConfiguration(embeddedView, it as com.facebook.react.bridge.Dynamic) } + 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() } } } 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/EmbeddedPaymentElementViewManager.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt index 9c9d27fba..d36a26d0e 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt @@ -2,6 +2,7 @@ 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 @@ -58,12 +59,12 @@ class EmbeddedPaymentElementViewManager : val readableMap = cfg.asMap() if (readableMap == null) return - val rowSelectionBehaviorType = parseRowSelectionBehavior(readableMap) + val bundle = toBundleObject(readableMap) + val rowSelectionBehaviorType = parseRowSelectionBehavior(bundle) view.rowSelectionBehaviorType.value = rowSelectionBehaviorType - val elementConfig = parseElementConfiguration(readableMap, view.context) + val elementConfig = parseElementConfiguration(bundle, view.context) view.latestElementConfig = elementConfig - // if intentConfig is already set, configure immediately: view.latestIntentConfig?.let { intentCfg -> view.configure(elementConfig, intentCfg) view.post { @@ -80,7 +81,8 @@ class EmbeddedPaymentElementViewManager : ) { val readableMap = cfg.asMap() if (readableMap == null) return - val intentConfig = parseIntentConfiguration(readableMap) + val bundle = toBundleObject(readableMap) + val intentConfig = parseIntentConfiguration(bundle) view.latestIntentConfig = intentConfig view.latestElementConfig?.let { elemCfg -> view.configure(elemCfg, intentConfig) @@ -92,23 +94,21 @@ class EmbeddedPaymentElementViewManager : ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi::class, ExperimentalCustomPaymentMethodsApi::class, ) - private fun parseElementConfiguration( - map: ReadableMap, + internal fun parseElementConfiguration( + bundle: Bundle, context: Context, ): EmbeddedPaymentElement.Configuration { - val merchantDisplayName = map.getString("merchantDisplayName").orEmpty() + val merchantDisplayName = bundle.getString("merchantDisplayName").orEmpty() val allowsDelayedPaymentMethods: Boolean = - if (map.hasKey("allowsDelayedPaymentMethods") && - map.getType("allowsDelayedPaymentMethods") == ReadableType.Boolean - ) { - map.getBoolean("allowsDelayedPaymentMethods") + if (bundle.containsKey("allowsDelayedPaymentMethods")) { + bundle.getBoolean("allowsDelayedPaymentMethods") } else { - false // default + false } var defaultBillingDetails: PaymentSheet.BillingDetails? = null - val billingDetailsMap = map.getMap("defaultBillingDetails") + val billingDetailsMap = bundle.getBundle("defaultBillingDetails") if (billingDetailsMap != null) { - val addressBundle = billingDetailsMap.getMap("address") + val addressBundle = billingDetailsMap.getBundle("address") val address = PaymentSheet.Address( addressBundle?.getString("city"), @@ -129,24 +129,24 @@ class EmbeddedPaymentElementViewManager : val customerConfiguration = try { - buildCustomerConfiguration(toBundleObject(map)) + buildCustomerConfiguration(bundle) } catch (error: PaymentSheetException) { - throw Error() // TODO handle error + throw Error() } - val googlePayConfig = buildGooglePayConfig(toBundleObject(map.getMap("googlePay"))) - val linkConfig = PaymentSheetFragment.buildLinkConfig(toBundleObject(map.getMap("link"))) + val googlePayConfig = buildGooglePayConfig(bundle.getBundle("googlePay")) + val linkConfig = PaymentSheetFragment.buildLinkConfig(bundle.getBundle("link")) val shippingDetails = - map.getMap("defaultShippingDetails")?.let { + bundle.getBundle("defaultShippingDetails")?.let { AddressSheetView.buildAddressDetails(it) } val appearance = try { - buildPaymentSheetAppearance(toBundleObject(map.getMap("appearance")), context) + buildPaymentSheetAppearance(bundle.getBundle("appearance"), context) } catch (error: PaymentSheetAppearanceException) { - throw Error() // TODO handle error + throw Error() } - val billingConfigParams = map.getMap("billingDetailsCollectionConfiguration") + val billingConfigParams = bundle.getBundle("billingDetailsCollectionConfiguration") val billingDetailsConfig = PaymentSheet.BillingDetailsCollectionConfiguration( name = mapToCollectionMode(billingConfigParams?.getString("name")), @@ -157,17 +157,17 @@ class EmbeddedPaymentElementViewManager : billingConfigParams?.getBooleanOr("attachDefaultsToPaymentMethod", false) ?: false, ) val allowsRemovalOfLastSavedPaymentMethod = - if (map.hasKey("allowsRemovalOfLastSavedPaymentMethod")) { - map.getBoolean("allowsRemovalOfLastSavedPaymentMethod") + if (bundle.containsKey("allowsRemovalOfLastSavedPaymentMethod")) { + bundle.getBoolean("allowsRemovalOfLastSavedPaymentMethod") } else { true } - val primaryButtonLabel = map.getString("primaryButtonLabel") - val paymentMethodOrder = map.getStringArrayList("paymentMethodOrder") + val primaryButtonLabel = bundle.getString("primaryButtonLabel") + val paymentMethodOrder = bundle.getStringArrayList("paymentMethodOrder") val formSheetAction = - map - .getMap("formSheetAction") + bundle + .getBundle("formSheetAction") ?.getString("type") ?.let { type -> when (type) { @@ -181,7 +181,7 @@ class EmbeddedPaymentElementViewManager : EmbeddedPaymentElement.Configuration .Builder(merchantDisplayName) .formSheetAction(formSheetAction) - .allowsDelayedPaymentMethods(allowsDelayedPaymentMethods ?: false) + .allowsDelayedPaymentMethods(allowsDelayedPaymentMethods) .defaultBillingDetails(defaultBillingDetails) .customer(customerConfiguration) .googlePay(googlePayConfig) @@ -191,27 +191,24 @@ class EmbeddedPaymentElementViewManager : .billingDetailsCollectionConfiguration(billingDetailsConfig) .preferredNetworks( mapToPreferredNetworks( - map + bundle .getIntegerArrayList("preferredNetworks") ?.let { ArrayList(it) }, ), ).allowsRemovalOfLastSavedPaymentMethod(allowsRemovalOfLastSavedPaymentMethod) - .cardBrandAcceptance(mapToCardBrandAcceptance(toBundleObject(map))) + .cardBrandAcceptance(mapToCardBrandAcceptance(bundle)) .embeddedViewDisplaysMandateText( - if (map.hasKey("embeddedViewDisplaysMandateText") && - map.getType("embeddedViewDisplaysMandateText") == ReadableType.Boolean - ) { - map.getBoolean("embeddedViewDisplaysMandateText") + if (bundle.containsKey("embeddedViewDisplaysMandateText")) { + bundle.getBoolean("embeddedViewDisplaysMandateText") } else { - true // default value + true }, ) - // Serialize original ReadableMap because toBundleObject cannot keep arrays of objects .customPaymentMethods( parseCustomPaymentMethods( - toBundleObject(map.getMap("customPaymentMethodConfiguration")).apply { - map.getMap("customPaymentMethodConfiguration")?.let { readable -> - putSerializable("customPaymentMethodConfigurationReadableMap", readable.toHashMap()) + bundle.getBundle("customPaymentMethodConfiguration").apply { + bundle.getBundle("customPaymentMethodConfiguration")?.let { readable -> + putSerializable("customPaymentMethodConfigurationReadableMap", readable) } }, ), @@ -223,10 +220,10 @@ class EmbeddedPaymentElementViewManager : return configurationBuilder.build() } - private fun parseRowSelectionBehavior(map: ReadableMap): RowSelectionBehaviorType { + internal fun parseRowSelectionBehavior(bundle: Bundle): RowSelectionBehaviorType { val rowSelectionBehavior = - map - .getMap("rowSelectionBehavior") + bundle + .getBundle("rowSelectionBehavior") ?.getString("type") ?.let { type -> when (type) { @@ -238,8 +235,8 @@ class EmbeddedPaymentElementViewManager : return rowSelectionBehavior } - private fun parseIntentConfiguration(map: ReadableMap): PaymentSheet.IntentConfiguration { - val intentConfig = PaymentSheetFragment.buildIntentConfiguration(toBundleObject(map)) + internal fun parseIntentConfiguration(bundle: Bundle): PaymentSheet.IntentConfiguration { + val intentConfig = PaymentSheetFragment.buildIntentConfiguration(bundle) return intentConfig ?: throw IllegalArgumentException("IntentConfiguration is null") } From 22134188a4b719a84f1c36adac70ed8901baad2f Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 12:53:33 +0545 Subject: [PATCH 08/17] Fix Android compilation errors and upgrade Gradle Fixed compilation errors in Bundle handling and upgraded Gradle to 8.11.1 to match React Native SDK. --- .../android/gradle/wrapper/gradle-wrapper.properties | 4 +++- .../EmbeddedPaymentElementViewManager.kt | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) 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/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt index d36a26d0e..2f4a94981 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementViewManager.kt @@ -154,7 +154,11 @@ class EmbeddedPaymentElementViewManager : email = mapToCollectionMode(billingConfigParams?.getString("email")), address = mapToAddressCollectionMode(billingConfigParams?.getString("address")), attachDefaultsToPaymentMethod = - billingConfigParams?.getBooleanOr("attachDefaultsToPaymentMethod", false) ?: false, + if (billingConfigParams?.containsKey("attachDefaultsToPaymentMethod") == true) { + billingConfigParams.getBoolean("attachDefaultsToPaymentMethod") + } else { + false + }, ) val allowsRemovalOfLastSavedPaymentMethod = if (bundle.containsKey("allowsRemovalOfLastSavedPaymentMethod")) { @@ -206,11 +210,7 @@ class EmbeddedPaymentElementViewManager : ) .customPaymentMethods( parseCustomPaymentMethods( - bundle.getBundle("customPaymentMethodConfiguration").apply { - bundle.getBundle("customPaymentMethodConfiguration")?.let { readable -> - putSerializable("customPaymentMethodConfigurationReadableMap", readable) - } - }, + bundle.getBundle("customPaymentMethodConfiguration") ?: Bundle(), ), ) From f8f2bf2fc420fc73222ee905b8ad0b2e2d3f2841 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 13:12:39 +0545 Subject: [PATCH 09/17] Fix Android color parsing when values are strings Bundle.getBundle() throws ClassCastException when value is a string. Wrapped in try-catch to mirror React Native's ReadableMap behavior. --- .../com/reactnativestripesdk/PaymentSheetAppearance.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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..5e8f479bf 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,12 @@ private fun dynamicColorFromParams( } // First check if it's a nested Bundle { "light": "#RRGGBB", "dark": "#RRGGBB" } - val colorBundle = params.getBundle(key) + val colorBundle = + try { + params.getBundle(key) + } catch (e: ClassCastException) { + null + } if (colorBundle != null) { val isDark = ( From 8784de207a7fcdb3b5cc14f392ed9777c1ac075d Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 13:29:26 +0545 Subject: [PATCH 10/17] Fix Bundle type check to avoid ClassCastException warnings Changed from try-catch to type check with Bundle.get() to avoid Android logging warnings when color values are strings. --- .../com/reactnativestripesdk/PaymentSheetAppearance.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 5e8f479bf..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,12 +511,9 @@ private fun dynamicColorFromParams( } // First check if it's a nested Bundle { "light": "#RRGGBB", "dark": "#RRGGBB" } - val colorBundle = - try { - params.getBundle(key) - } catch (e: ClassCastException) { - null - } + val value = params.get(key) + val colorBundle = if (value is Bundle) value else null + if (colorBundle != null) { val isDark = ( From 145e5d4094ef761bf057393d969b8c60d35d4551 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Tue, 14 Oct 2025 13:59:25 +0545 Subject: [PATCH 11/17] Fix Android shimmer not hiding in embedded payment element Height changes were sent to wrong channel. Widget now receives events directly from platform view instead of global emitter. Added callbacks for all events: - onHeightChanged - onPaymentOptionChanged - onLoadingFailed - onRowSelectionImmediateAction - onFormSheetConfirmComplete Matches iOS behavior. --- ...peSdkEmbeddedPaymentElementPlatformView.kt | 20 ++++ .../EmbeddedPaymentElementView.kt | 99 ++++++++++--------- 2 files changed, 74 insertions(+), 45 deletions(-) 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 index eb46f1190..9ae85b304 100644 --- 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 @@ -26,6 +26,26 @@ class StripeSdkEmbeddedPaymentElementPlatformView( 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 = { message -> + channel.invokeMethod("embeddedPaymentElementLoadingFailed", mapOf("message" to message)) + } + + 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<*, *> 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 index 19381b71c..a5a3b049a 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt @@ -65,6 +65,11 @@ class EmbeddedPaymentElementView( val rowSelectionBehaviorType = mutableStateOf(null) var onConfirmResult: ((Map) -> Unit)? = null + var onHeightChanged: ((Float) -> Unit)? = null + var onPaymentOptionChanged: ((Map?) -> Unit)? = null + var onLoadingFailed: ((String) -> Unit)? = null + var onRowSelectionImmediateAction: (() -> Unit)? = null + var onFormSheetConfirmComplete: ((Map) -> Unit)? = null private val reactContext get() = context as ThemedReactContext private val events = Channel(Channel.UNLIMITED) @@ -202,37 +207,35 @@ class EmbeddedPaymentElementView( } }, resultCallback = { result -> - val map = - Arguments.createMap().apply { - when (result) { - is EmbeddedPaymentElement.Result.Completed -> { - putString("status", "completed") - } + 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")) + } - is EmbeddedPaymentElement.Result.Canceled -> { - putString("status", "canceled") - } + onConfirmResult?.invoke(resultMap) - is EmbeddedPaymentElement.Result.Failed -> { - putString("status", "failed") - putString("error", result.error.message ?: "Unknown error") + 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") + } } } - } - - // Call Flutter method channel result callback - onConfirmResult?.invoke( - 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")) - } - ) - - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementFormSheetConfirmComplete(map) + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementFormSheetConfirmComplete(map) + } }, ).confirmCustomPaymentMethodCallback(confirmCustomPaymentMethodCallback) .rowSelectionBehavior( @@ -240,7 +243,9 @@ class EmbeddedPaymentElementView( EmbeddedPaymentElement.RowSelectionBehavior.default() } else { EmbeddedPaymentElement.RowSelectionBehavior.immediateAction { - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementRowSelectionImmediateAction() + onRowSelectionImmediateAction?.invoke() ?: run { + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementRowSelectionImmediateAction() + } } }, ) @@ -266,15 +271,15 @@ class EmbeddedPaymentElementView( when (result) { is EmbeddedPaymentElement.ConfigureResult.Succeeded -> reportHeightChange(1f) is EmbeddedPaymentElement.ConfigureResult.Failed -> { - // send the error back to JS val err = result.error val msg = err.localizedMessage ?: err.toString() - // build a RN map - val payload = - Arguments.createMap().apply { - putString("message", msg) - } - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed(payload) + onLoadingFailed?.invoke(msg) ?: run { + val payload = + Arguments.createMap().apply { + putString("message", msg) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed(payload) + } } } } @@ -292,11 +297,13 @@ class EmbeddedPaymentElementView( LaunchedEffect(embedded) { embedded.paymentOption.collect { opt -> val optMap = opt?.toWritableMap() - val payload = - Arguments.createMap().apply { - putMap("paymentOption", optMap) - } - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdatePaymentOption(payload) + onPaymentOptionChanged?.invoke(optMap) ?: run { + val payload = + Arguments.createMap().apply { + putMap("paymentOption", optMap) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdatePaymentOption(payload) + } } } @@ -356,11 +363,13 @@ class EmbeddedPaymentElementView( } private fun reportHeightChange(height: Float) { - val params = - Arguments.createMap().apply { - putDouble("height", height.toDouble()) - } - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdateHeight(params) + onHeightChanged?.invoke(height) ?: run { + val params = + Arguments.createMap().apply { + putDouble("height", height.toDouble()) + } + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementDidUpdateHeight(params) + } } // APIs From f32ddb1a36319d4de5a8badddb9cb796d54b5c9e Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Thu, 16 Oct 2025 19:25:14 +0545 Subject: [PATCH 12/17] fix: fix height, component overflow --- .../src/widgets/embedded_payment_element.dart | 13 ++++++++--- .../EmbeddedPaymentElementFactory.swift | 23 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/stripe/lib/src/widgets/embedded_payment_element.dart b/packages/stripe/lib/src/widgets/embedded_payment_element.dart index 8cd0cdd56..b0ef70973 100644 --- a/packages/stripe/lib/src/widgets/embedded_payment_element.dart +++ b/packages/stripe/lib/src/widgets/embedded_payment_element.dart @@ -161,6 +161,8 @@ class _EmbeddedPaymentElementState extends State final arguments = call.arguments as Map?; if (arguments != null) { final height = (arguments['height'] as num?)?.toDouble() ?? 0; + if (height <= 0) return; + setState(() { _currentHeight = height; }); @@ -215,9 +217,14 @@ class _EmbeddedPaymentElementState extends State ); } - return SizedBox( - height: _currentHeight > 0 ? _currentHeight : 400, - child: platformView, + return AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: SizedBox( + height: _currentHeight > 0 ? _currentHeight : 400, + child: platformView, + ), ); } } 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 index 62cb04061..39aa6548c 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift @@ -109,7 +109,7 @@ class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { @MainActor private func attachEmbeddedView(_ embeddedElement: EmbeddedPaymentElement) { - delegate = FlutterEmbeddedPaymentElementDelegate(channel: channel, embeddedView: embeddedView) + delegate = FlutterEmbeddedPaymentElementDelegate(channel: channel) embeddedElement.delegate = delegate let paymentElementView = embeddedElement.view @@ -156,20 +156,27 @@ class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { class FlutterEmbeddedPaymentElementDelegate: EmbeddedPaymentElementDelegate { weak var channel: FlutterMethodChannel? - weak var embeddedView: UIView? + private var lastReportedHeight: CGFloat = 0 - init(channel: FlutterMethodChannel, embeddedView: UIView) { + init(channel: FlutterMethodChannel) { self.channel = channel - self.embeddedView = embeddedView } func embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: StripePaymentSheet.EmbeddedPaymentElement) { guard let channel = channel else { return } - let newHeight = embeddedPaymentElement.view.systemLayoutSizeFitting( - CGSize(width: embeddedPaymentElement.view.bounds.width, height: UIView.layoutFittingCompressedSize.height) - ).height + let paymentView = embeddedPaymentElement.view + paymentView.layoutIfNeeded() + let targetSize = paymentView.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize + ) + let newHeight = targetSize.height + + guard newHeight > 0 else { return } + guard abs(newHeight - lastReportedHeight) > 1.0 else { return } + + lastReportedHeight = newHeight channel.invokeMethod("onHeightChanged", arguments: ["height": newHeight]) } @@ -181,7 +188,7 @@ class FlutterEmbeddedPaymentElementDelegate: EmbeddedPaymentElementDelegate { } func embeddedPaymentElementWillPresent(embeddedPaymentElement: EmbeddedPaymentElement) { - if let viewController = embeddedView?.window?.rootViewController { + if let viewController = embeddedPaymentElement.view.window?.rootViewController { embeddedPaymentElement.presentingViewController = viewController } } From 9ef800674767791fe4b4849f06670e8564b70593 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Mon, 20 Oct 2025 15:53:59 +0545 Subject: [PATCH 13/17] chore: add paymentMethodConfigurationId Payment Method Configuration to IntentConfiguration --- .../PaymentSheetFragment.kt | 1 + .../Stripe Sdk/StripeSdkImpl+Embedded.swift | 2 + .../StripeSdkImpl+PaymentSheet.swift | 3 ++ .../lib/src/models/payment_sheet.dart | 4 ++ .../lib/src/models/payment_sheet.freezed.dart | 47 +++++++++++-------- .../lib/src/models/payment_sheet.g.dart | 3 ++ 6 files changed, 40 insertions(+), 20 deletions(-) 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/Stripe Sdk/StripeSdkImpl+Embedded.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift index 170b45399..7e4e44dab 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,6 +31,7 @@ extension StripeSdkImpl { let intentConfig = buildIntentConfiguration( modeParams: modeParams, paymentMethodTypes: intentConfig["paymentMethodTypes"] as? [String], + paymentMethodConfigurationId: intentConfig["paymentMethodConfigurationId"] as? String, captureMethod: mapCaptureMethod(captureMethodString) ) @@ -132,6 +133,7 @@ extension StripeSdkImpl { let intentConfiguration = buildIntentConfiguration( modeParams: modeParams, paymentMethodTypes: intentConfig["paymentMethodTypes"] as? [String], + paymentMethodConfigurationId: intentConfig["paymentMethodConfigurationId"] as? String, captureMethod: mapCaptureMethod(captureMethodString) ) 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 c3435da83..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([ 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 2a737b252..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) 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 0517c3fc9..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?, )); } 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 fdbd9b0d2..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( From 2b2bca7c397362cdf2363336bbe2d6064db716d6 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Mon, 20 Oct 2025 17:52:44 +0545 Subject: [PATCH 14/17] fix: avoid embedded element height reentry on iOS simulator --- .../Stripe Sdk/StripeSdkImpl+Embedded.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 7e4e44dab..6983f2459 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 @@ -172,14 +172,34 @@ extension StripeSdkImpl { 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) { From 1d52b725b4c93a4ad844c8ee1418e7c6d2c19bf2 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Mon, 20 Oct 2025 22:02:06 +0545 Subject: [PATCH 15/17] fix: avoid embedded element height reentry on iOS simulator --- .../EmbeddedPaymentElementFactory.swift | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) 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 index 39aa6548c..f7725ec72 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift @@ -157,27 +157,38 @@ class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { 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 let channel = channel else { return } - - let paymentView = embeddedPaymentElement.view - paymentView.layoutIfNeeded() - - let targetSize = paymentView.systemLayoutSizeFitting( - UIView.layoutFittingCompressedSize - ) - let newHeight = targetSize.height + 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 - lastReportedHeight) > 1.0 else { return } + guard newHeight > 0 else { return } + guard abs(newHeight - self.lastReportedHeight) > 1.0 else { return } - lastReportedHeight = newHeight - channel.invokeMethod("onHeightChanged", arguments: ["height": newHeight]) + // 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) { From 28d16d8f4aa995b9715f326d2cb08e1fd58d4cc2 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Sat, 15 Nov 2025 02:38:28 +0545 Subject: [PATCH 16/17] chore: improve error handling and return data --- .../src/widgets/embedded_payment_element.dart | 74 +++++++++++++- ...peSdkEmbeddedPaymentElementPlatformView.kt | 6 +- .../EmbeddedPaymentElementView.kt | 98 +++++++++++++++++-- .../EmbeddedPaymentElementFactory.swift | 22 ++++- .../Stripe Sdk/StripeSdkImpl+Embedded.swift | 67 +++++++++++-- 5 files changed, 237 insertions(+), 30 deletions(-) diff --git a/packages/stripe/lib/src/widgets/embedded_payment_element.dart b/packages/stripe/lib/src/widgets/embedded_payment_element.dart index b0ef70973..e8dc06291 100644 --- a/packages/stripe/lib/src/widgets/embedded_payment_element.dart +++ b/packages/stripe/lib/src/widgets/embedded_payment_element.dart @@ -13,7 +13,9 @@ typedef PaymentOptionChangedCallback = typedef HeightChangedCallback = void Function(double height); /// Called when the embedded payment element fails to load. -typedef LoadingFailedCallback = void Function(String message); +typedef LoadingFailedCallback = void Function( + EmbeddedPaymentElementLoadingException error, +); /// Called when form sheet confirmation completes. typedef FormSheetConfirmCompleteCallback = @@ -22,6 +24,29 @@ typedef FormSheetConfirmCompleteCallback = /// 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. @@ -170,9 +195,8 @@ class _EmbeddedPaymentElementState extends State } break; case 'embeddedPaymentElementLoadingFailed': - final arguments = call.arguments as Map?; - final message = arguments?['message'] as String? ?? 'Unknown error'; - widget.onLoadingFailed?.call(message); + final error = _parseLoadingError(call.arguments); + widget.onLoadingFailed?.call(error); break; case 'embeddedPaymentElementFormSheetConfirmComplete': final arguments = call.arguments as Map?; @@ -190,6 +214,48 @@ class _EmbeddedPaymentElementState extends State } } + 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 = { 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 index 9ae85b304..ad1cbd5e3 100644 --- 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 @@ -2,8 +2,8 @@ package com.flutter.stripe import android.content.Context import android.view.View -import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.ThemedReactContext +import com.reactnativestripesdk.EmbeddedPaymentElementLoadingError import com.reactnativestripesdk.EmbeddedPaymentElementView import com.reactnativestripesdk.EmbeddedPaymentElementViewManager import com.reactnativestripesdk.StripeSdkModule @@ -34,8 +34,8 @@ class StripeSdkEmbeddedPaymentElementPlatformView( channel.invokeMethod("onPaymentOptionChanged", mapOf("paymentOption" to paymentOption)) } - embeddedView.onLoadingFailed = { message -> - channel.invokeMethod("embeddedPaymentElementLoadingFailed", mapOf("message" to message)) + embeddedView.onLoadingFailed = { error: EmbeddedPaymentElementLoadingError -> + channel.invokeMethod("embeddedPaymentElementLoadingFailed", error.toMap()) } embeddedView.onRowSelectionImmediateAction = { 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 index a5a3b049a..831a64d97 100644 --- a/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt +++ b/packages/stripe_android/android/src/main/kotlin/com/reactnativestripesdk/EmbeddedPaymentElementView.kt @@ -20,11 +20,13 @@ 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 @@ -44,6 +46,31 @@ enum class RowSelectionBehaviorType { 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, @@ -67,7 +94,7 @@ class EmbeddedPaymentElementView( var onConfirmResult: ((Map) -> Unit)? = null var onHeightChanged: ((Float) -> Unit)? = null var onPaymentOptionChanged: ((Map?) -> Unit)? = null - var onLoadingFailed: ((String) -> Unit)? = null + var onLoadingFailed: ((EmbeddedPaymentElementLoadingError) -> Unit)? = null var onRowSelectionImmediateAction: (() -> Unit)? = null var onFormSheetConfirmComplete: ((Map) -> Unit)? = null @@ -271,14 +298,11 @@ class EmbeddedPaymentElementView( when (result) { is EmbeddedPaymentElement.ConfigureResult.Succeeded -> reportHeightChange(1f) is EmbeddedPaymentElement.ConfigureResult.Failed -> { - val err = result.error - val msg = err.localizedMessage ?: err.toString() - onLoadingFailed?.invoke(msg) ?: run { - val payload = - Arguments.createMap().apply { - putString("message", msg) - } - requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed(payload) + val errorPayload = result.error.asEmbeddedPaymentElementLoadingError() + onLoadingFailed?.invoke(errorPayload) ?: run { + requireStripeSdkModule().eventEmitter.emitEmbeddedPaymentElementLoadingFailed( + errorPayload.toWritableMap(), + ) } } } @@ -390,3 +414,59 @@ class EmbeddedPaymentElementView( 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_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift index f7725ec72..7d37bf3f9 100644 --- a/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift +++ b/packages/stripe_ios/ios/stripe_ios/Sources/stripe_ios/EmbeddedPaymentElementFactory.swift @@ -86,9 +86,15 @@ class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { guard let self = self else { return } if let resultDict = result as? NSDictionary, - let error = resultDict["error"] as? NSDictionary, - let message = error["message"] as? String { - self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": message]) + 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 } @@ -102,7 +108,15 @@ class EmbeddedPaymentElementPlatformView: NSObject, FlutterPlatformView { reject: { [weak self] code, message, error in guard let self = self else { return } let errorMessage = message ?? error?.localizedDescription ?? "Unknown error" - self.channel.invokeMethod("embeddedPaymentElementLoadingFailed", arguments: ["message": errorMessage]) + 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) } ) } 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 6983f2459..83d3585a6 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 @@ -72,15 +72,20 @@ extension StripeSdkImpl { embeddedInstanceDelegate.embeddedPaymentElementDidUpdateHeight(embeddedPaymentElement: embeddedPaymentElement) embeddedInstanceDelegate.embeddedPaymentElementDidUpdatePaymentOption(embeddedPaymentElement: embeddedPaymentElement) } catch { - let msg = error.localizedDescription - - if self.emitter != nil { - self.emitter?.emitEmbeddedPaymentElementLoadingFailed(["message": msg]) - } else { - //TODO HANDLE emitter nil - } - - resolve(nil) + 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 } } @@ -152,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) } @@ -166,6 +182,37 @@ extension StripeSdkImpl { } } + 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) + } + + 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 From 6fea095628d518cde09f1242e90579c2d0cbff29 Mon Sep 17 00:00:00 2001 From: Aditya Shah Date: Sat, 15 Nov 2025 22:50:07 +0545 Subject: [PATCH 17/17] fix: nonobjc error --- .../Sources/stripe_ios/Stripe Sdk/StripeSdkImpl+Embedded.swift | 2 ++ 1 file changed, 2 insertions(+) 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 83d3585a6..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 @@ -182,6 +182,7 @@ extension StripeSdkImpl { } } + @nonobjc private func extractEmbeddedPaymentElementErrorInfo( from details: NSDictionary?, fallbackMessage: String, @@ -194,6 +195,7 @@ extension StripeSdkImpl { return (message, code) } + @nonobjc private func dispatchEmbeddedPaymentElementLoadingFailed( message: String, code: String?,