diff --git a/README.md b/README.md index 8716117..f91df60 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,9 @@ await MonextPayment.startPayment( `ArgumentError` if `sessionToken` is empty, and a `MonextPaymentChannelUnavailableException` if no native handler is registered for the `MethodChannel`. + +## Try it + +The [`example`](example) app is a runnable test screen with a session token field, an environment +picker and a **Buy** button wired to `startPayment`. See [`example/README.md`](example/README.md) +for how to run it and get a test session token. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 726a661..07e2922 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -23,13 +23,22 @@ allprojects { plugins { id("com.android.library") - id("org.jetbrains.kotlin.android") - // Unlike the plugins above (bundled in kotlin-gradle-plugin/AGP and already resolvable via - // the buildscript classpath below without a version), the Compose compiler is a separate + // Unlike the plugin below (bundled in kotlin-gradle-plugin/AGP and already resolvable via + // the buildscript classpath above without a version), the Compose compiler is a separate // Gradle Plugin Portal artifact and needs an explicit version here to resolve. id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" } +// AGP 9+ ships Kotlin support built in, so applying the Kotlin Gradle Plugin (KGP) directly is +// both redundant and, per Flutter's migration guide, will eventually break consuming apps' builds +// (Flutter is dropping support for plugins that apply KGP). Only apply it for AGP < 9, which has +// no built-in Kotlin support of its own. +// See https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-plugin-authors +val agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt() +if (agpMajor < 9) { + apply(plugin = "org.jetbrains.kotlin.android") +} + android { namespace = "io.lenra.monext_payment" @@ -58,6 +67,10 @@ android { defaultConfig { // Monext's Android SDK requires Android 8.0 (API 26) or later. minSdk = 26 + + // See consumer-rules.pro: Compose UI's optional Android XR support otherwise fails full + // R8 minification ("Missing classes detected") for any app consuming this plugin. + consumerProguardFiles("consumer-rules.pro") } testOptions { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..d83ad71 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,11 @@ +# Jetpack Compose UI's optional Android XR (SceneCore/SplitEngine) support code references +# OEM-only XR extension classes (com.android.extensions.xr.*) that don't ship in the standard +# Android SDK/on non-XR devices. Full-mode R8 fails minification with "Missing classes detected" +# for apps consuming this plugin (which pulls in Compose UI to host Monext's PaymentBox) even +# though this code path is never reached on a normal device. See +# https://issuetracker.google.com/issues/326315287 and Flutter/AGP's own "Missing classes +# detected while running R8" guidance: add -dontwarn rather than a fully qualified keep rule, +# since these classes intentionally aren't on the classpath. +-dontwarn com.android.extensions.xr.** +-dontwarn com.google.androidxr.splitengine.** +-dontwarn com.google.imp.splitengine.** diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4e3b56d..1460d25 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,10 +1,14 @@ - + + android:theme="@style/Theme.MonextPayment.Transparent" /> diff --git a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt index bb9d960..45c0f2f 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt @@ -1,9 +1,7 @@ package io.lenra.monext_payment import android.app.Activity -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContract +import android.content.Intent import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -11,13 +9,15 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import org.json.JSONObject /** MonextPaymentPlugin */ class MonextPaymentPlugin : FlutterPlugin, MethodCallHandler, - ActivityAware { + ActivityAware, + ActivityResultListener { // The MethodChannel that will the communication between Flutter and native Android // // This local reference serves to register the plugin with the Flutter Engine and unregister it @@ -31,12 +31,20 @@ class MonextPaymentPlugin : // plugin's public API. internal lateinit var sdkClient: MonextSdkClient - // Launches PaymentActivity and delivers its result. Only available while an Activity is - // attached (see onAttachedToActivity/onDetachedFromActivity); registered via - // ActivityResultRegistry directly (rather than ComponentActivity.registerForActivityResult) - // because the plugin doesn't control the host Activity's onCreate, so it can't rely on - // registering before the Activity reaches STARTED. - private var paymentLauncher: ActivityResultLauncher? = null + // The Activity currently hosting the Flutter engine, and the binding used to (un)register this + // plugin as an ActivityResultListener. Only available while an Activity is attached (see + // onAttachedToActivity/onDetachedFromActivity). + // + // Launching PaymentActivity goes through the legacy Activity.startActivityForResult() / + // PluginRegistry.ActivityResultListener APIs rather than AndroidX's ActivityResultRegistry: + // the latter requires the host Activity to be a ComponentActivity, but + // io.flutter.embedding.android.FlutterActivity — the Activity subclass used by the vast + // majority of Flutter apps, including this plugin's own example app — extends plain + // android.app.Activity, not ComponentActivity. Relying on ActivityResultRegistry meant + // startPayment() failed with "activity_unavailable" on every single app using a stock + // FlutterActivity. + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "monext_payment") @@ -75,8 +83,8 @@ class MonextPaymentPlugin : return } - val launcher = paymentLauncher - if (launcher == null) { + val currentActivity = activity + if (currentActivity == null) { result.error( "activity_unavailable", "No Android Activity is attached to host the payment UI", @@ -89,14 +97,16 @@ class MonextPaymentPlugin : val googlePayConfigurationJson = call.argument>("googlePayConfiguration")?.let { JSONObject(it).toString() } val language = call.argument("language") - launcher.launch( - PaymentActivity.LaunchArgs( - sessionToken = sessionToken, - environmentName = environmentName!!, - appearanceJson = appearanceJson, - language = language, - googlePayConfigurationJson = googlePayConfigurationJson, - ) + currentActivity.startActivityForResult( + PaymentActivity.createIntent( + currentActivity, + sessionToken, + environmentName!!, + appearanceJson, + language, + googlePayConfigurationJson + ), + PAYMENT_REQUEST_CODE ) result.success(null) } @@ -106,12 +116,9 @@ class MonextPaymentPlugin : } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - val activity = binding.activity as? ComponentActivity ?: return - paymentLauncher = - activity.activityResultRegistry.register( - "monext_payment/paymentActivity", - PaymentActivityResultContract() - ) { wireValue -> sendPaymentResult(wireValue) } + activity = binding.activity + activityBinding = binding + binding.addActivityResultListener(this) } override fun onDetachedFromActivityForConfigChanges() = detachFromActivity() @@ -121,7 +128,33 @@ class MonextPaymentPlugin : override fun onDetachedFromActivity() = detachFromActivity() private fun detachFromActivity() { - paymentLauncher = null + activityBinding?.removeActivityResultListener(this) + activityBinding = null + activity = null + } + + /** + * Receives [PaymentActivity]'s result once it finishes, decodes it into a `MonextPaymentResult` + * wire value, and relays it to Dart. Falls back to `"cancelled"` for a bare + * [Activity.RESULT_CANCELED] with no result extra (e.g. the system back gesture finishing the + * Activity before it had a chance to set its own result). + * + * Returns `true` only for [PAYMENT_REQUEST_CODE], as required by + * [ActivityResultListener]'s contract: other listeners registered on the same Activity still + * need a chance to handle unrelated request codes. + */ + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + if (requestCode != PAYMENT_REQUEST_CODE) return false + + val wireValue = + data?.getStringExtra(PaymentActivity.EXTRA_RESULT) + ?: if (resultCode == Activity.RESULT_CANCELED) PaymentActivity.RESULT_CANCELLED else null + sendPaymentResult(wireValue) + return true } /** @@ -139,27 +172,14 @@ class MonextPaymentPlugin : companion object { private const val UNKNOWN_RESULT = "unknown" - } -} -/** - * [androidx.activity.result.contract.ActivityResultContract] launching [PaymentActivity] and - * decoding its result extra back into a `MonextPaymentResult` wire value, falling back to - * `"cancelled"` for a bare [Activity.RESULT_CANCELED] (e.g. the system back gesture finishing the - * Activity before it had a chance to set its own result). - */ -private class PaymentActivityResultContract : ActivityResultContract() { - override fun createIntent(context: android.content.Context, input: PaymentActivity.LaunchArgs) = - PaymentActivity.createIntent( - context, - input.sessionToken, - input.environmentName, - input.appearanceJson, - input.language, - input.googlePayConfigurationJson, - ) - - override fun parseResult(resultCode: Int, intent: android.content.Intent?): String? = - intent?.getStringExtra(PaymentActivity.EXTRA_RESULT) - ?: if (resultCode == Activity.RESULT_CANCELED) PaymentActivity.RESULT_CANCELLED else null + // Arbitrary but distinctive request code identifying PaymentActivity's result among any + // other startActivityForResult() calls the host app/other plugins might make. Kept within + // 16 bits: some AndroidX call paths (e.g. Fragment.startActivityForResult) reserve the + // upper bits for their own bookkeeping and reject request codes outside that range. + // + // Internal (not private) so it stays testable, e.g. so tests can drive onActivityResult() + // directly without going through a real (unmockable in plain JVM unit tests) Intent. + internal const val PAYMENT_REQUEST_CODE = 0x4D4E + } } diff --git a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt index f900767..4cf6071 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt @@ -14,7 +14,10 @@ import com.monext.sdk.PaymentBox import org.json.JSONObject /** - * Full-screen wrapper `Activity` hosting Monext's `PaymentBox` in a `ComposeView` (see ADR 0001). + * Wrapper `Activity` hosting Monext's `PaymentBox` in a `ComposeView` (see ADR 0001). Declared + * with a transparent theme in the manifest (see `styles.xml`) since `PaymentBox` renders itself + * as a bottom sheet, not a full page — a transparent window lets the host app's Activity show + * through behind it instead of an opaque backdrop. * * `PaymentBox` is a Jetpack Compose component with no direct equivalent to iOS's * `presentPaymentSheet`, so this Activity exists solely to give it somewhere to live. It carries @@ -104,15 +107,6 @@ internal class PaymentActivity : ComponentActivity() { .putExtra(EXTRA_LANGUAGE, language) .putExtra(EXTRA_GOOGLE_PAY_CONFIGURATION, googlePayConfigurationJson) } - - /** Input type for the [androidx.activity.result.contract.ActivityResultContract] launching this Activity. */ - internal data class LaunchArgs( - val sessionToken: String, - val environmentName: String, - val appearanceJson: String?, - val language: String?, - val googlePayConfigurationJson: String?, - ) } /** Shallow-converts a [JSONObject] into a `Map` for [MonextAppearanceMapper]. */ diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml new file mode 100644 index 0000000..2ecdc21 --- /dev/null +++ b/android/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt index 64d0feb..e2148c1 100644 --- a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt +++ b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt @@ -1,11 +1,15 @@ package io.lenra.monext_payment +import android.app.Activity +import android.content.Intent import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.mockito.Mockito import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue /* @@ -151,4 +155,96 @@ internal class MonextPaymentPluginTest { Mockito.isNull() ) } + + private fun mockActivityBinding(mockActivity: Activity): ActivityPluginBinding { + val mockBinding: ActivityPluginBinding = Mockito.mock(ActivityPluginBinding::class.java) + Mockito.`when`(mockBinding.activity).thenReturn(mockActivity) + return mockBinding + } + + /** + * Regression test for a bug where startPayment() always failed with "activity_unavailable": + * the plugin relied on AndroidX's ActivityResultRegistry, which requires the host Activity to + * be a ComponentActivity — but io.flutter.embedding.android.FlutterActivity (used by the vast + * majority of Flutter apps) extends plain android.app.Activity, not ComponentActivity, so the + * registration silently never happened. onAttachedToActivity() must accept a plain Activity + * (no cast/early-return) and onDetachedFromActivity() must release it again, rather than + * leaking a stale registration/listener across re-attachment. + * + * (The full startPayment() happy path — actually launching PaymentActivity — isn't covered by + * a unit test here: it goes through PaymentActivity.createIntent(), which builds a real + * android.content.Intent, and this project has no Robolectric to back that in plain JVM unit + * tests. That path is exercised by the "Build example app for Android" CI step instead.) + */ + @Test + fun onDetachedFromActivity_removesTheActivityResultListener() { + val plugin = attachedPlugin() + val mockActivity: Activity = Mockito.mock(Activity::class.java) + val mockBinding = mockActivityBinding(mockActivity) + + plugin.onAttachedToActivity(mockBinding) + plugin.onDetachedFromActivity() + + Mockito.verify(mockBinding).removeActivityResultListener(plugin) + + // With the Activity detached, startPayment() must fail again instead of using a stale + // reference to the Activity it's no longer attached to. + val call = + MethodCall( + "startPayment", + mapOf("sessionToken" to "a-session-token", "environment" to "sandbox") + ) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).error( + Mockito.eq("activity_unavailable"), + Mockito.anyString(), + Mockito.isNull() + ) + } + + // These onActivityResult tests drive the listener callback directly with a *mocked* Intent + // (or none at all) rather than going through startPayment()'s real PaymentActivity.createIntent() + // call: constructing/mutating a real android.content.Intent throws in plain JVM unit tests + // (no Robolectric in this project), since it hits the unimplemented Android SDK stub jar. + + @Test + fun onActivityResult_forThePaymentRequestCode_relaysTheResultExtraAndReturnsTrue() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + val mockResultIntent: Intent = Mockito.mock(Intent::class.java) + Mockito.`when`(mockResultIntent.getStringExtra(PaymentActivity.EXTRA_RESULT)).thenReturn("captured") + + val handled = + plugin.onActivityResult(MonextPaymentPlugin.PAYMENT_REQUEST_CODE, Activity.RESULT_OK, mockResultIntent) + + assertTrue(handled) + Mockito.verify(mockChannel).invokeMethod("onPaymentResult", "captured") + } + + @Test + fun onActivityResult_forAnUnrelatedRequestCode_returnsFalseWithoutRelayingAnything() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + + val handled = plugin.onActivityResult(-1, Activity.RESULT_OK, null) + + assertFalse(handled) + Mockito.verify(mockChannel, Mockito.never()).invokeMethod(Mockito.anyString(), Mockito.any()) + } + + @Test + fun onActivityResult_withBareResultCanceledAndNoExtra_relaysCancelled() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + + val handled = plugin.onActivityResult(MonextPaymentPlugin.PAYMENT_REQUEST_CODE, Activity.RESULT_CANCELED, null) + + assertTrue(handled) + Mockito.verify(mockChannel).invokeMethod("onPaymentResult", "cancelled") + } } diff --git a/example/README.md b/example/README.md index ffd9041..0026d62 100644 --- a/example/README.md +++ b/example/README.md @@ -1,17 +1,50 @@ # monext_payment_example -Demonstrates how to use the monext_payment plugin. +Demonstrates how to use the `monext_payment` plugin: a single screen with a session token field, +an environment picker, and a **Buy** button that launches Monext's native payment UI and displays +the resulting `MonextPaymentResult`. -## Getting Started +## Prerequisites -This project is a starting point for a Flutter application. +- A working Flutter setup (`flutter doctor`). +- This app depends on `monext_payment` via a local path dependency (`../`), so no extra setup is + needed for the plugin itself. See the root + [Project Setup Requirements](../docs/guides/project-setup-requirements.md) for native minimum + versions if you fork this example into a standalone app. +- A **session token**. This app never generates one itself — Monext requires session tokens to be + created server-side (see + [Security: Server-Side Session Token Handling](../docs/guides/security-server-side-session-token-handling.md)). + For quick manual testing without a full backend, use a sandbox token generated through Monext's + own tools/dashboard for your test merchant account. -A few resources to get you started if this is your first Flutter project: +## Running the app -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +``` +cd example +flutter pub get +flutter run +``` -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Pick a device/simulator when prompted. Google Pay/Apple Pay wallet buttons require extra native +setup ([Google Pay Setup](../docs/guides/google-pay-setup.md), +[Apple Pay Setup](../docs/guides/apple-pay-setup.md)) and are not wired up in this demo. + +## Using the test screen + +1. Paste a sandbox session token into the **Session token** field. +2. Pick **sandbox** or **production** in the **Environment** dropdown (leave it on `sandbox` for + testing). +3. Tap **Buy**. This calls `MonextPayment.startPayment(...)`, which opens Monext's native payment + UI full-screen on top of the app. +4. Complete or cancel the flow in the native UI. Once it closes, the screen below the button shows + the decoded `MonextPaymentResult` (e.g. `captured`, `failed`, `cancelled`) or an error if + `startPayment` itself threw (e.g. empty token). + +`MonextPayment.initialize()` is called once in `initState`, before the button becomes usable, as +required by the plugin's API. + +## Code + +See [`lib/main.dart`](lib/main.dart) for the full integration: it's intentionally minimal so it +doubles as a copy-pasteable starting point. For the full Dart API (all `startPayment` parameters, +exceptions, wallet configuration), see the [root README](../README.md). diff --git a/example/lib/main.dart b/example/lib/main.dart index 53c874f..b3af09e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,58 +1,139 @@ import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter/services.dart'; import 'package:monext_payment/monext_payment.dart'; void main() { runApp(const MyApp()); } -class MyApp extends StatefulWidget { +class MyApp extends StatelessWidget { const MyApp({super.key}); @override - State createState() => _MyAppState(); + Widget build(BuildContext context) { + return MaterialApp( + title: 'monext_payment example', + home: const PaymentDemoPage(), + ); + } +} + +/// Demo page showing the minimal integration of `monext_payment`: a form to +/// fill in a session token/environment and a "Buy" button that starts the +/// native payment flow and displays the resulting [MonextPaymentResult]. +/// +/// The session token must come from a merchant backend (see +/// `docs/guides/security-server-side-session-token-handling.md`); this demo +/// only lets you paste one in manually for testing, it never generates one. +class PaymentDemoPage extends StatefulWidget { + const PaymentDemoPage({super.key}); + + @override + State createState() => _PaymentDemoPageState(); } -class _MyAppState extends State { - String _platformVersion = 'Unknown'; - final _monextPaymentPlugin = MonextPayment(); +class _PaymentDemoPageState extends State { + final _sessionTokenController = TextEditingController(); + MonextEnvironment _environment = MonextEnvironment.sandbox; + bool _isPaying = false; + String _status = 'No payment started yet.'; @override void initState() { super.initState(); - initPlatformState(); + MonextPayment.initialize(); } - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. - try { - platformVersion = - await _monextPaymentPlugin.getPlatformVersion() ?? 'Unknown platform version'; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } + @override + void dispose() { + _sessionTokenController.dispose(); + super.dispose(); + } - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; + Future _buy() async { + final sessionToken = _sessionTokenController.text.trim(); + if (sessionToken.isEmpty) { + setState(() => _status = 'Enter a session token before paying.'); + return; + } setState(() { - _platformVersion = platformVersion; + _isPaying = true; + _status = 'Starting payment...'; }); + + try { + await MonextPayment.startPayment( + sessionToken: sessionToken, + environment: _environment, + appearance: const MonextAppearance(headerTitle: 'monext_payment example'), + onPaymentResult: (result) { + if (!mounted) return; + setState(() { + _isPaying = false; + _status = 'Result: ${MonextPaymentResult.fromValue(result).name}'; + }); + }, + ); + } catch (e) { + if (!mounted) return; + setState(() { + _isPaying = false; + _status = 'Error: $e'; + }); + } } @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar(title: const Text('Plugin example app')), - body: Center(child: Text('Running on: $_platformVersion\n')), + return Scaffold( + appBar: AppBar(title: const Text('monext_payment example')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Paste a Monext session token generated by your backend ' + '(see docs/guides/security-server-side-session-token-handling.md), ' + 'pick an environment, then tap Buy to launch the native payment UI.', + ), + const SizedBox(height: 16), + TextField( + controller: _sessionTokenController, + decoration: const InputDecoration( + labelText: 'Session token', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _environment, + decoration: const InputDecoration( + labelText: 'Environment', + border: OutlineInputBorder(), + ), + items: MonextEnvironment.values + .map((e) => DropdownMenuItem(value: e, child: Text(e.name))) + .toList(), + onChanged: (value) { + if (value != null) setState(() => _environment = value); + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isPaying ? null : _buy, + child: _isPaying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Buy'), + ), + const SizedBox(height: 24), + Text(_status), + ], + ), ), ); } diff --git a/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift b/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift index 87a3f2f..09fce3e 100644 --- a/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift +++ b/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift @@ -90,7 +90,13 @@ public class MonextPaymentPlugin: NSObject, FlutterPlugin { self?.finishPaymentPresentation(with: wireValue) } )) - hostingController.modalPresentationStyle = .fullScreen + // PaymentHostingView's own content is just Color.clear applying the presentPaymentSheet + // modifier (see PaymentHostingView.swift), which renders as a sheet, not a full page. With + // .fullScreen, UIKit paints an opaque backdrop behind that transparent content instead of + // showing the presenting app underneath. .overFullScreen keeps the presenter visible/live + // behind this controller, and clearing the hosting view's background lets it show through. + hostingController.modalPresentationStyle = .overFullScreen + hostingController.view.backgroundColor = .clear presentedController = hostingController presenter.present(hostingController, animated: true)