-
-
Notifications
You must be signed in to change notification settings - Fork 649
feat: add PaymentMethodMessaging widget (via PaymentMethodMessagingElement) #2403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f4943f
e378923
2a1dcfc
5c84af0
6d63683
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import 'package:flutter/foundation.dart'; | ||
| import 'package:flutter/gestures.dart'; | ||
| import 'package:flutter/rendering.dart'; | ||
| import 'package:flutter/services.dart'; | ||
| import 'package:flutter/widgets.dart'; | ||
| import 'package:stripe_platform_interface/stripe_platform_interface.dart'; | ||
|
|
||
| /// Renders Stripe's Klarna / Afterpay / Affirm promotional messaging element. | ||
| /// | ||
| /// Backed by Stripe's `PaymentMethodMessagingElement` on both iOS (via | ||
| /// `StripePaymentSheet`) and Android (via the `com.stripe:payment-method-messaging` | ||
| /// artifact). The element is currently marked preview by Stripe (`@_spi` on | ||
| /// iOS, `@OptIn(PaymentMethodMessagingElementPreview::class)` on Android), so | ||
| /// its visual output may change in future SDK releases. | ||
| /// | ||
| /// The widget auto-resizes: the native element reports its rendered height | ||
| /// back to Dart, which drives the surrounding `SizedBox`. The optional | ||
| /// [onHeightChange] callback receives the same value if callers want to react | ||
| /// to height changes themselves. | ||
| class PaymentMethodMessaging extends StatefulWidget { | ||
| const PaymentMethodMessaging({ | ||
| required this.configuration, | ||
| this.onHeightChange, | ||
| super.key, | ||
| }); | ||
|
|
||
| final PaymentMethodMessagingConfiguration configuration; | ||
| final ValueChanged<double>? onHeightChange; | ||
|
|
||
| @override | ||
| State<PaymentMethodMessaging> createState() => | ||
| _PaymentMethodMessagingState(); | ||
| } | ||
|
|
||
| class _PaymentMethodMessagingState extends State<PaymentMethodMessaging> { | ||
| static const _viewType = 'flutter.stripe/payment_method_messaging'; | ||
|
|
||
| MethodChannel? _methodChannel; | ||
| double _height = 50; | ||
|
|
||
| void onPlatformViewCreated(int viewId) { | ||
| _methodChannel = | ||
| MethodChannel('flutter.stripe/payment_method_messaging/$viewId'); | ||
| _methodChannel!.setMethodCallHandler((call) async { | ||
| if (call.method == 'onHeightChange') { | ||
| final args = Map<String, dynamic>.from(call.arguments); | ||
| final height = (args['height'] as num).toDouble(); | ||
| if (mounted) { | ||
| setState(() { | ||
| _height = height; | ||
| }); | ||
| } | ||
| widget.onHeightChange?.call(height); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(covariant PaymentMethodMessaging oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.configuration != oldWidget.configuration) { | ||
| _methodChannel?.invokeMethod( | ||
| 'updateConfiguration', | ||
| widget.configuration.toJson(), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| _methodChannel?.setMethodCallHandler(null); | ||
| super.dispose(); | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final creationParams = widget.configuration.toJson(); | ||
|
|
||
| Widget platform; | ||
| if (defaultTargetPlatform == TargetPlatform.iOS) { | ||
| platform = UiKitView( | ||
| viewType: _viewType, | ||
| creationParamsCodec: const StandardMessageCodec(), | ||
| creationParams: creationParams, | ||
| onPlatformViewCreated: onPlatformViewCreated, | ||
| ); | ||
| } else if (defaultTargetPlatform == TargetPlatform.android) { | ||
| platform = PlatformViewLink( | ||
| viewType: _viewType, | ||
| surfaceFactory: (context, controller) { | ||
| return AndroidViewSurface( | ||
| controller: controller as AndroidViewController, | ||
| hitTestBehavior: PlatformViewHitTestBehavior.opaque, | ||
| gestureRecognizers: | ||
| const <Factory<OneSequenceGestureRecognizer>>{}, | ||
| ); | ||
| }, | ||
| onCreatePlatformView: (params) { | ||
| onPlatformViewCreated(params.id); | ||
| return PlatformViewsService.initSurfaceAndroidView( | ||
| id: params.id, | ||
| viewType: _viewType, | ||
| layoutDirection: TextDirection.ltr, | ||
| creationParams: creationParams, | ||
| creationParamsCodec: const StandardMessageCodec(), | ||
| ) | ||
| ..addOnPlatformViewCreatedListener( | ||
| params.onPlatformViewCreated, | ||
| ) | ||
| ..create(); | ||
| }, | ||
| ); | ||
| } else { | ||
| throw UnsupportedError('Unsupported platform view'); | ||
| } | ||
|
|
||
| return SizedBox( | ||
| height: _height, | ||
| child: platform, | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| package com.flutter.stripe | ||
|
|
||
| import android.app.Activity | ||
| import android.app.Application | ||
| import android.content.Context | ||
| import android.content.ContextWrapper | ||
| import android.view.View | ||
| import android.widget.FrameLayout | ||
| import androidx.compose.foundation.layout.Box | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.layout.onSizeChanged | ||
| import androidx.compose.ui.platform.ComposeView | ||
| import androidx.compose.ui.platform.LocalDensity | ||
| import androidx.compose.ui.platform.ViewCompositionStrategy | ||
| import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElement | ||
| import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElementPreview | ||
| import com.stripe.android.model.PaymentMethod | ||
| import io.flutter.plugin.common.MethodCall | ||
| import io.flutter.plugin.common.MethodChannel | ||
| import io.flutter.plugin.platform.PlatformView | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.cancel | ||
| import kotlinx.coroutines.launch | ||
|
|
||
| @OptIn(PaymentMethodMessagingElementPreview::class) | ||
| class StripePaymentMethodMessagingPlatformView( | ||
| private val context: Context, | ||
| private val channel: MethodChannel, | ||
| creationParams: Map<String?, Any?>?, | ||
| ) : PlatformView { | ||
|
|
||
| private val container: FrameLayout = FrameLayout(context) | ||
| private val composeView: ComposeView = ComposeView(context).apply { | ||
| setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | ||
| } | ||
| private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) | ||
| private var configureJob: Job? = null | ||
| private var elementState: ((PaymentMethodMessagingElement?) -> Unit)? = null | ||
| private var lastReportedHeightPx: Int = -1 | ||
|
|
||
| init { | ||
| container.addView( | ||
| composeView, | ||
| FrameLayout.LayoutParams( | ||
| FrameLayout.LayoutParams.MATCH_PARENT, | ||
| FrameLayout.LayoutParams.WRAP_CONTENT, | ||
| ), | ||
| ) | ||
| composeView.setContent { | ||
| var element by remember { mutableStateOf<PaymentMethodMessagingElement?>(null) } | ||
| elementState = { element = it } | ||
| val density = LocalDensity.current | ||
| Box( | ||
| modifier = Modifier.onSizeChanged { size -> | ||
| if (size.height != lastReportedHeightPx) { | ||
| lastReportedHeightPx = size.height | ||
| val heightDp = with(density) { size.height.toDp().value } | ||
| channel.invokeMethod( | ||
| "onHeightChange", | ||
| mapOf("height" to heightDp.toDouble()), | ||
| ) | ||
| } | ||
| }, | ||
| ) { | ||
| element?.Content(PaymentMethodMessagingElement.Appearance()) | ||
| } | ||
| } | ||
|
|
||
| channel.setMethodCallHandler { call, result -> handleMethodCall(call, result) } | ||
| applyConfiguration(creationParams) | ||
|
Comment on lines
+45
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.kt | head -150Repository: flutter-stripe/flutter_stripe Length of output: 6860 🏁 Script executed: # Check the entire file to understand the full context
wc -l packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.ktRepository: flutter-stripe/flutter_stripe Length of output: 187 🏁 Script executed: # Look for the applyConfiguration method
rg -A 30 "fun applyConfiguration" packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.ktRepository: flutter-stripe/flutter_stripe Length of output: 1405 Move element state to class-level Compose state to avoid race condition.
Suggested fix import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var configureJob: Job? = null
- private var elementState: ((PaymentMethodMessagingElement?) -> Unit)? = null
+ private var currentElement by mutableStateOf<PaymentMethodMessagingElement?>(null)
private var lastReportedHeightPx: Int = -1
@@
)
composeView.setContent {
- var element by remember { mutableStateOf<PaymentMethodMessagingElement?>(null) }
- elementState = { element = it }
val density = LocalDensity.current
Box(
@@
) {
- element?.Content(PaymentMethodMessagingElement.Appearance())
+ currentElement?.Content(PaymentMethodMessagingElement.Appearance())
}
}
@@
private fun applyConfiguration(params: Map<String?, Any?>?) {
configureJob?.cancel()
- elementState?.invoke(null)
+ currentElement = null
emitCollapsedHeight()
@@
configureJob = scope.launch {
- val element = PaymentMethodMessagingElement.create(application)
- val result = element.configure(configuration)
+ val configuredElement = PaymentMethodMessagingElement.create(application)
+ val result = configuredElement.configure(configuration)
when (result) {
is PaymentMethodMessagingElement.ConfigureResult.Succeeded -> {
- elementState?.invoke(element)
+ currentElement = configuredElement
}
is PaymentMethodMessagingElement.ConfigureResult.NoContent,
is PaymentMethodMessagingElement.ConfigureResult.Failed -> {
- elementState?.invoke(null)
+ currentElement = null
emitCollapsedHeight()
}
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) { | ||
| when (call.method) { | ||
| "updateConfiguration" -> { | ||
| @Suppress("UNCHECKED_CAST") | ||
| applyConfiguration(call.arguments as? Map<String?, Any?>) | ||
| result.success(null) | ||
| } | ||
| else -> result.notImplemented() | ||
| } | ||
| } | ||
|
|
||
| private fun applyConfiguration(params: Map<String?, Any?>?) { | ||
| configureJob?.cancel() | ||
| elementState?.invoke(null) | ||
| emitCollapsedHeight() | ||
|
|
||
| if (params == null) return | ||
|
|
||
| @Suppress("UNCHECKED_CAST") | ||
| val methodStrings = params["paymentMethods"] as? List<String> ?: return | ||
| val currency = params["currency"] as? String ?: return | ||
| val amount = (params["amount"] as? Number)?.toLong() ?: return | ||
| val countryCode = params["countryCode"] as? String | ||
| val locale = params["locale"] as? String | ||
|
|
||
| val types = methodStrings.mapNotNull { PaymentMethod.Type.fromCode(it) } | ||
| val application = context.findApplication() ?: return | ||
|
|
||
| val configuration = PaymentMethodMessagingElement.Configuration().apply { | ||
| amount(amount) | ||
| currency(currency) | ||
| locale?.let { locale(it) } | ||
| countryCode?.let { countryCode(it) } | ||
| if (types.isNotEmpty()) paymentMethodTypes(types) | ||
| } | ||
|
|
||
| configureJob = scope.launch { | ||
| val element = PaymentMethodMessagingElement.create(application) | ||
| val result = element.configure(configuration) | ||
| when (result) { | ||
| is PaymentMethodMessagingElement.ConfigureResult.Succeeded -> { | ||
| elementState?.invoke(element) | ||
| } | ||
| is PaymentMethodMessagingElement.ConfigureResult.NoContent, | ||
| is PaymentMethodMessagingElement.ConfigureResult.Failed -> { | ||
| elementState?.invoke(null) | ||
| emitCollapsedHeight() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun emitCollapsedHeight() { | ||
| if (lastReportedHeightPx != 0) { | ||
| lastReportedHeightPx = 0 | ||
| channel.invokeMethod("onHeightChange", mapOf("height" to 0.0)) | ||
| } | ||
| } | ||
|
|
||
| override fun getView(): View = container | ||
|
|
||
| override fun dispose() { | ||
| configureJob?.cancel() | ||
| scope.cancel() | ||
| composeView.disposeComposition() | ||
| channel.setMethodCallHandler(null) | ||
| } | ||
| } | ||
|
|
||
| private fun Context.findApplication(): Application? { | ||
| var ctx: Context = this | ||
| while (ctx is ContextWrapper) { | ||
| if (ctx is Application) return ctx | ||
| if (ctx is Activity) return ctx.application | ||
| ctx = ctx.baseContext | ||
| } | ||
| return applicationContext as? Application | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.flutter.stripe | ||
|
|
||
| import android.content.Context | ||
| 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 StripePaymentMethodMessagingPlatformViewFactory( | ||
| private val flutterPluginBinding: FlutterPlugin.FlutterPluginBinding, | ||
| ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { | ||
| override fun create(context: Context?, viewId: Int, args: Any?): PlatformView { | ||
| if (context == null) { | ||
| throw AssertionError("Context is not allowed to be null when launching PaymentMethodMessaging view.") | ||
| } | ||
| val channel = MethodChannel( | ||
| flutterPluginBinding.binaryMessenger, | ||
| "flutter.stripe/payment_method_messaging/$viewId", | ||
| ) | ||
| @Suppress("UNCHECKED_CAST") | ||
| val creationParams = args as? Map<String?, Any?>? | ||
| return StripePaymentMethodMessagingPlatformView(context, channel, creationParams) | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: flutter-stripe/flutter_stripe
Length of output: 454
Fix version mismatch:
com.stripe:payment-method-messagingdoes not publish 23.1.x versions.The artifact
com.stripe:payment-method-messagingon Maven Central only provides versions up to21.19.0. There are no versions in the23.xtrain or23.1.xrange. Using23.1.+will cause Gradle resolution to fail at build time.Pin this dependency to
21.19.0or the lowest compatible version that providesPaymentMethodMessagingElement, or align it with a version range that actually exists for this artifact.🤖 Prompt for AI Agents