|
1 | 1 | package com.flutter.stripe |
2 | 2 |
|
3 | | -import android.app.Activity |
4 | | -import android.app.Application |
5 | 3 | import android.content.Context |
6 | | -import android.content.ContextWrapper |
7 | 4 | import android.view.View |
8 | | -import android.widget.FrameLayout |
9 | | -import androidx.compose.foundation.layout.Box |
10 | | -import androidx.compose.runtime.getValue |
11 | | -import androidx.compose.runtime.mutableStateOf |
12 | | -import androidx.compose.runtime.remember |
13 | | -import androidx.compose.runtime.setValue |
14 | | -import androidx.compose.ui.Modifier |
15 | | -import androidx.compose.ui.layout.onSizeChanged |
16 | | -import androidx.compose.ui.platform.ComposeView |
17 | | -import androidx.compose.ui.platform.LocalDensity |
18 | | -import androidx.compose.ui.platform.ViewCompositionStrategy |
19 | | -import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElement |
20 | | -import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElementPreview |
21 | | -import com.stripe.android.model.PaymentMethod |
| 5 | +import com.facebook.react.bridge.DynamicFromObject |
| 6 | +import com.facebook.react.bridge.ReadableMap |
| 7 | +import com.facebook.react.modules.core.DeviceEventManagerModule |
| 8 | +import com.facebook.react.uimanager.ThemedReactContext |
| 9 | +import com.reactnativestripesdk.PaymentMethodMessagingElementView |
| 10 | +import com.reactnativestripesdk.PaymentMethodMessagingElementViewManager |
| 11 | +import com.reactnativestripesdk.StripeSdkModule |
22 | 12 | import io.flutter.plugin.common.MethodCall |
23 | 13 | import io.flutter.plugin.common.MethodChannel |
24 | 14 | import io.flutter.plugin.platform.PlatformView |
25 | | -import kotlinx.coroutines.CoroutineScope |
26 | | -import kotlinx.coroutines.Dispatchers |
27 | | -import kotlinx.coroutines.Job |
28 | | -import kotlinx.coroutines.SupervisorJob |
29 | | -import kotlinx.coroutines.cancel |
30 | | -import kotlinx.coroutines.launch |
31 | 15 |
|
32 | | -@OptIn(PaymentMethodMessagingElementPreview::class) |
| 16 | +/** |
| 17 | + * Thin wrapper around the Stripe SDK's React Native messaging view + its |
| 18 | + * [PaymentMethodMessagingElementViewManager] (vendored from stripe-react-native). |
| 19 | + * |
| 20 | + * Props (`configuration`, optional `appearance`) are pushed through the view manager, |
| 21 | + * exactly like the other native-SDK-backed views (see [StripeAubecsDebitPlatformView]). |
| 22 | + * The RN view reports its height and configure result through the shared module event |
| 23 | + * emitter; we register this view's per-view channel for the `paymentMethodMessagingElement` |
| 24 | + * event prefix so those events reach the matching Dart widget — the same mechanism |
| 25 | + * [StripeSdkEmbeddedPaymentElementPlatformView] uses. |
| 26 | + * |
| 27 | + * Nested creation params (the `paymentMethodTypes` array inside `configuration`) are |
| 28 | + * converted via [asReadableMap] rather than the flat `convertToReadables()` helper, so the |
| 29 | + * backing JSONObject auto-wraps inner lists/maps correctly. |
| 30 | + */ |
33 | 31 | class StripePaymentMethodMessagingPlatformView( |
34 | | - private val context: Context, |
| 32 | + context: Context, |
35 | 33 | private val channel: MethodChannel, |
| 34 | + id: Int, |
36 | 35 | creationParams: Map<String?, Any?>?, |
37 | | -) : PlatformView { |
| 36 | + private val viewManager: PaymentMethodMessagingElementViewManager, |
| 37 | + sdkAccessor: () -> StripeSdkModule, |
| 38 | +) : PlatformView, MethodChannel.MethodCallHandler { |
38 | 39 |
|
39 | | - private val container: FrameLayout = FrameLayout(context) |
40 | | - private val composeView: ComposeView = ComposeView(context).apply { |
41 | | - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) |
42 | | - } |
43 | | - private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) |
44 | | - private var configureJob: Job? = null |
45 | | - private var elementState: ((PaymentMethodMessagingElement?) -> Unit)? = null |
46 | | - private var lastReportedHeightPx: Int = -1 |
| 40 | + private val messagingView: PaymentMethodMessagingElementView = |
| 41 | + viewManager.createViewInstance( |
| 42 | + ThemedReactContext(sdkAccessor().reactContext, channel, sdkAccessor), |
| 43 | + ) |
47 | 44 |
|
48 | 45 | init { |
49 | | - container.addView( |
50 | | - composeView, |
51 | | - FrameLayout.LayoutParams( |
52 | | - FrameLayout.LayoutParams.MATCH_PARENT, |
53 | | - FrameLayout.LayoutParams.WRAP_CONTENT, |
54 | | - ), |
55 | | - ) |
56 | | - composeView.setContent { |
57 | | - var element by remember { mutableStateOf<PaymentMethodMessagingElement?>(null) } |
58 | | - elementState = { element = it } |
59 | | - val density = LocalDensity.current |
60 | | - Box( |
61 | | - modifier = Modifier.onSizeChanged { size -> |
62 | | - if (size.height != lastReportedHeightPx) { |
63 | | - lastReportedHeightPx = size.height |
64 | | - val heightDp = with(density) { size.height.toDp().value } |
65 | | - channel.invokeMethod( |
66 | | - "onHeightChange", |
67 | | - mapOf("height" to heightDp.toDouble()), |
68 | | - ) |
69 | | - } |
70 | | - }, |
71 | | - ) { |
72 | | - element?.Content(PaymentMethodMessagingElement.Appearance()) |
73 | | - } |
74 | | - } |
| 46 | + channel.setMethodCallHandler(this) |
| 47 | + DeviceEventManagerModule.RCTDeviceEventEmitter.registerChannelForPrefix(EVENT_PREFIX, channel) |
| 48 | + applyProps(creationParams) |
| 49 | + } |
75 | 50 |
|
76 | | - channel.setMethodCallHandler { call, result -> handleMethodCall(call, result) } |
77 | | - applyConfiguration(creationParams) |
| 51 | + override fun getView(): View = messagingView |
| 52 | + |
| 53 | + override fun onFlutterViewAttached(flutterView: View) { |
| 54 | + viewManager.onAfterUpdateTransaction(messagingView) |
78 | 55 | } |
79 | 56 |
|
80 | | - private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) { |
| 57 | + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { |
81 | 58 | when (call.method) { |
82 | 59 | "updateConfiguration" -> { |
83 | 60 | @Suppress("UNCHECKED_CAST") |
84 | | - applyConfiguration(call.arguments as? Map<String?, Any?>) |
| 61 | + applyProps(call.arguments as? Map<String?, Any?>) |
85 | 62 | result.success(null) |
86 | 63 | } |
87 | 64 | else -> result.notImplemented() |
88 | 65 | } |
89 | 66 | } |
90 | 67 |
|
91 | | - private fun applyConfiguration(params: Map<String?, Any?>?) { |
92 | | - configureJob?.cancel() |
93 | | - elementState?.invoke(null) |
94 | | - emitCollapsedHeight() |
95 | | - |
96 | | - if (params == null) return |
97 | | - |
98 | | - @Suppress("UNCHECKED_CAST") |
99 | | - val methodStrings = params["paymentMethods"] as? List<String> ?: return |
100 | | - val currency = params["currency"] as? String ?: return |
101 | | - val amount = (params["amount"] as? Number)?.toLong() ?: return |
102 | | - val countryCode = params["countryCode"] as? String |
103 | | - val locale = params["locale"] as? String |
104 | | - |
105 | | - val types = methodStrings.mapNotNull { PaymentMethod.Type.fromCode(it) } |
106 | | - val application = context.findApplication() ?: return |
| 68 | + override fun dispose() { |
| 69 | + DeviceEventManagerModule.RCTDeviceEventEmitter.unregisterChannelForPrefix(EVENT_PREFIX) |
| 70 | + viewManager.onDropViewInstance(messagingView) |
| 71 | + channel.setMethodCallHandler(null) |
| 72 | + } |
107 | 73 |
|
108 | | - val configuration = PaymentMethodMessagingElement.Configuration().apply { |
109 | | - amount(amount) |
110 | | - currency(currency) |
111 | | - locale?.let { locale(it) } |
112 | | - countryCode?.let { countryCode(it) } |
113 | | - if (types.isNotEmpty()) paymentMethodTypes(types) |
| 74 | + private fun applyProps(params: Map<String?, Any?>?) { |
| 75 | + asReadableMap(params?.get("appearance"))?.let { |
| 76 | + viewManager.setAppearance(messagingView, DynamicFromObject(it)) |
114 | 77 | } |
115 | | - |
116 | | - configureJob = scope.launch { |
117 | | - val element = PaymentMethodMessagingElement.create(application) |
118 | | - val result = element.configure(configuration) |
119 | | - when (result) { |
120 | | - is PaymentMethodMessagingElement.ConfigureResult.Succeeded -> { |
121 | | - elementState?.invoke(element) |
122 | | - } |
123 | | - is PaymentMethodMessagingElement.ConfigureResult.NoContent, |
124 | | - is PaymentMethodMessagingElement.ConfigureResult.Failed -> { |
125 | | - elementState?.invoke(null) |
126 | | - emitCollapsedHeight() |
127 | | - } |
128 | | - } |
| 78 | + asReadableMap(params?.get("configuration"))?.let { |
| 79 | + viewManager.setConfiguration(messagingView, DynamicFromObject(it)) |
129 | 80 | } |
130 | 81 | } |
131 | 82 |
|
132 | | - private fun emitCollapsedHeight() { |
133 | | - if (lastReportedHeightPx != 0) { |
134 | | - lastReportedHeightPx = 0 |
135 | | - channel.invokeMethod("onHeightChange", mapOf("height" to 0.0)) |
136 | | - } |
| 83 | + private fun asReadableMap(value: Any?): ReadableMap? { |
| 84 | + if (value !is Map<*, *>) return null |
| 85 | + @Suppress("UNCHECKED_CAST") |
| 86 | + return ReadableMap(normalizeMap(value) as Map<String, Any>) |
137 | 87 | } |
138 | 88 |
|
139 | | - override fun getView(): View = container |
| 89 | + private fun normalizeMap(value: Map<*, *>): Map<String, Any?> = |
| 90 | + value.entries |
| 91 | + .filter { it.key is String } |
| 92 | + .associate { (key, entryValue) -> key as String to normalizeValue(entryValue) } |
140 | 93 |
|
141 | | - override fun dispose() { |
142 | | - configureJob?.cancel() |
143 | | - scope.cancel() |
144 | | - composeView.disposeComposition() |
145 | | - channel.setMethodCallHandler(null) |
146 | | - } |
147 | | -} |
| 94 | + private fun normalizeValue(value: Any?): Any? = |
| 95 | + when (value) { |
| 96 | + is Map<*, *> -> normalizeMap(value) |
| 97 | + is List<*> -> value.map { normalizeValue(it) } |
| 98 | + else -> value |
| 99 | + } |
148 | 100 |
|
149 | | -private fun Context.findApplication(): Application? { |
150 | | - var ctx: Context = this |
151 | | - while (ctx is ContextWrapper) { |
152 | | - if (ctx is Application) return ctx |
153 | | - if (ctx is Activity) return ctx.application |
154 | | - ctx = ctx.baseContext |
| 101 | + private companion object { |
| 102 | + const val EVENT_PREFIX = "paymentMethodMessagingElement" |
155 | 103 | } |
156 | | - return applicationContext as? Application |
157 | 104 | } |
0 commit comments