diff --git a/pay/example/ios/Flutter/AppFrameworkInfo.plist b/pay/example/ios/Flutter/AppFrameworkInfo.plist index 4f8d4d24..8c6e5614 100644 --- a/pay/example/ios/Flutter/AppFrameworkInfo.plist +++ b/pay/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/pay/example/ios/Podfile b/pay/example/ios/Podfile index 88359b22..279576f3 100644 --- a/pay/example/ios/Podfile +++ b/pay/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +# platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/pay/example/ios/Podfile.lock b/pay/example/ios/Podfile.lock index 33c5b2fa..5e87ce2e 100644 --- a/pay/example/ios/Podfile.lock +++ b/pay/example/ios/Podfile.lock @@ -19,10 +19,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/pay_ios/ios" SPEC CHECKSUMS: - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 pay_ios: 8c7beb9c61d885f3f51b61f75f8793023fc8843a -PODFILE CHECKSUM: fc81e398f362bae88bdf55239bd5cf842faad39f +PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 -COCOAPODS: 1.11.3 +COCOAPODS: 1.15.2 diff --git a/pay/example/ios/Runner.xcodeproj/project.pbxproj b/pay/example/ios/Runner.xcodeproj/project.pbxproj index 20ee7217..b1d1dbe0 100644 --- a/pay/example/ios/Runner.xcodeproj/project.pbxproj +++ b/pay/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -170,7 +170,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -237,10 +237,12 @@ }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -268,6 +270,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); diff --git a/pay/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/pay/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..5e31d3d3 100644 --- a/pay/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/pay/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/pay/example/lib/main.dart b/pay/example/lib/main.dart index cc5710d3..7b7e8e62 100644 --- a/pay/example/lib/main.dart +++ b/pay/example/lib/main.dart @@ -35,18 +35,19 @@ class PayMaterialApp extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( + return MaterialApp( title: 'Pay for Flutter Demo', - localizationsDelegates: [ + localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, ], - supportedLocales: [ + supportedLocales: const [ Locale('en', ''), Locale('es', ''), Locale('de', ''), ], - home: PaySampleApp(), + theme: ThemeData.light(), + home: const PaySampleApp(), ); } } @@ -64,16 +65,23 @@ class _PaySampleAppState extends State { @override void initState() { super.initState(); - _googlePayConfigFuture = - PaymentConfiguration.fromAsset('default_google_pay_config.json'); + _googlePayConfigFuture = PaymentConfiguration.fromAsset( + 'default_google_pay_config.json', + ); } void onGooglePayResult(paymentResult) { debugPrint(paymentResult.toString()); } - void onApplePayResult(paymentResult) { + void onApplePayResult(paymentResult, ApplePaymentConfirmation handler) async { debugPrint(paymentResult.toString()); + + // This is where the payment result is fetched from the backend, and the + // payment result is updated accordingly. + bool isPaymentSuccessfull = true; + + await handler.updatePaymentResult(isPaymentSuccessfull); } @override @@ -128,23 +136,25 @@ class _PaySampleAppState extends State { ), // Example pay button configured using an asset FutureBuilder( - future: _googlePayConfigFuture, - builder: (context, snapshot) => snapshot.hasData - ? GooglePayButton( - paymentConfiguration: snapshot.data!, - paymentItems: _paymentItems, - type: GooglePayButtonType.buy, - margin: const EdgeInsets.only(top: 15.0), - onPaymentResult: onGooglePayResult, - loadingIndicator: const Center( - child: CircularProgressIndicator(), - ), - ) - : const SizedBox.shrink()), + future: _googlePayConfigFuture, + builder: (context, snapshot) => snapshot.hasData + ? GooglePayButton( + paymentConfiguration: snapshot.data!, + paymentItems: _paymentItems, + type: GooglePayButtonType.buy, + margin: const EdgeInsets.only(top: 15.0), + onPaymentResult: onGooglePayResult, + loadingIndicator: const Center( + child: CircularProgressIndicator(), + ), + ) + : const SizedBox.shrink(), + ), // Example pay button configured using a string ApplePayButton( paymentConfiguration: PaymentConfiguration.fromJsonString( - payment_configurations.defaultApplePay), + payment_configurations.defaultApplePay, + ), paymentItems: _paymentItems, style: ApplePayButtonStyle.black, type: ApplePayButtonType.buy, diff --git a/pay/lib/src/pay.dart b/pay/lib/src/pay.dart index 97cc75fd..64bf2e61 100644 --- a/pay/lib/src/pay.dart +++ b/pay/lib/src/pay.dart @@ -40,7 +40,10 @@ class Pay { /// Creates an instance with a dictionary of [_configurations] and /// instantiates the [_payPlatform] to communicate with the native platforms. - Pay(this._configurations) : _payPlatform = PayMethodChannel(); + Pay(this._configurations) + : _payPlatform = defaultTargetPlatform == TargetPlatform.iOS + ? IosPayMethodChannel() + : PayMethodChannel(); /// Determines whether a user can pay with the selected [provider]. /// @@ -70,6 +73,19 @@ class Pay { _configurations[provider]!, paymentItems); } + /// Update the payment result with the native platform. + /// Works only on iOS. + Future updatePaymentResult(bool isSuccess) async { + if (_payPlatform is IosPayMethodChannel) { + final iosPayPlatform = _payPlatform as IosPayMethodChannel; + return iosPayPlatform.updatePaymentResult(isSuccess); + } else { + throw MethodNotForCurrentPlatformException( + 'The method "updatePaymentResult" can only be called when the "defaultTargetPlatform" is iOS.', + ); + } + } + /// Verifies that the selected provider has been previously configured or /// throws otherwise. Future throwIfProviderIsNotDefined(PayProvider provider) async { @@ -91,3 +107,15 @@ class ProviderNotConfiguredException implements Exception { @override String toString() => 'ProviderNotConfiguredException: $message'; } + +/// Thrown to indicate that the method called is not available for the current +/// platform is has been called from. +class MethodNotForCurrentPlatformException implements Exception { + MethodNotForCurrentPlatformException(this.message); + + /// A human-readable error message, possibly null. + final String? message; + + @override + String toString() => 'MethodNotForCurrentPlatformException: $message'; +} diff --git a/pay/lib/src/widgets/apple_pay_button.dart b/pay/lib/src/widgets/apple_pay_button.dart index 2016667d..6355e76f 100644 --- a/pay/lib/src/widgets/apple_pay_button.dart +++ b/pay/lib/src/widgets/apple_pay_button.dart @@ -14,6 +14,18 @@ part of '../../pay.dart'; +class ApplePaymentConfirmation { + final Pay _payClient; + ApplePaymentConfirmation(Pay payClient) : _payClient = payClient; + + Future updatePaymentResult(bool completedSuccessfully) async { + await _payClient.updatePaymentResult(completedSuccessfully); + } +} + +typedef ApplePaymentConfirmCallback = Function( + Map result, ApplePaymentConfirmation handler); + /// A widget to show the Apple Pay button according to the rules and constraints /// specified in [PayButton]. /// @@ -38,7 +50,7 @@ class ApplePayButton extends PayButton { super.key, super.buttonProvider = PayProvider.apple_pay, required super.paymentConfiguration, - super.onPaymentResult, + required ApplePaymentConfirmCallback onPaymentResult, required List paymentItems, double? cornerRadius, ApplePayButtonStyle style = ApplePayButtonStyle.black, @@ -51,7 +63,11 @@ class ApplePayButton extends PayButton { super.childOnError, super.loadingIndicator, }) : assert(width >= RawApplePayButton.minimumButtonWidth), - assert(height >= RawApplePayButton.minimumButtonHeight) { + assert(height >= RawApplePayButton.minimumButtonHeight), + super(paymentCallback: (result) { + final pay = Pay({buttonProvider: paymentConfiguration}); + onPaymentResult(result, ApplePaymentConfirmation(pay)); + }) { _applePayButton = RawApplePayButton( style: style, type: type, diff --git a/pay/lib/src/widgets/google_pay_button.dart b/pay/lib/src/widgets/google_pay_button.dart index ec273662..2031390b 100644 --- a/pay/lib/src/widgets/google_pay_button.dart +++ b/pay/lib/src/widgets/google_pay_button.dart @@ -14,6 +14,8 @@ part of '../../pay.dart'; +typedef GooglePaymentCallback = Function(Map result); + /// A widget to show the Google Pay button according to the rules and /// constraints specified in [PayButton]. /// @@ -38,7 +40,7 @@ class GooglePayButton extends PayButton { super.key, super.buttonProvider = PayProvider.google_pay, required final PaymentConfiguration paymentConfiguration, - super.onPaymentResult, + required GooglePaymentCallback onPaymentResult, required List paymentItems, int cornerRadius = RawGooglePayButton.defaultButtonHeight ~/ 2, GooglePayButtonTheme theme = GooglePayButtonTheme.dark, @@ -52,7 +54,12 @@ class GooglePayButton extends PayButton { super.loadingIndicator, }) : assert(width >= RawGooglePayButton.minimumButtonWidth), assert(height >= RawGooglePayButton.defaultButtonHeight), - super(paymentConfiguration: paymentConfiguration) { + super( + paymentConfiguration: paymentConfiguration, + paymentCallback: (result) { + onPaymentResult(result); + }, + ) { _googlePayButton = RawGooglePayButton( paymentConfiguration: paymentConfiguration, cornerRadius: cornerRadius, diff --git a/pay/lib/src/widgets/pay_button.dart b/pay/lib/src/widgets/pay_button.dart index 55337e9c..1b3264ca 100644 --- a/pay/lib/src/widgets/pay_button.dart +++ b/pay/lib/src/widgets/pay_button.dart @@ -14,6 +14,8 @@ part of '../../pay.dart'; +typedef PaymentResultCallback = void Function(Map result); + /// A widget that handles the API logic to facilitate the integration. /// /// This widget provides an alternative UI-based integration path that wraps @@ -27,13 +29,13 @@ part of '../../pay.dart'; /// method which starts the payment process. abstract class PayButton extends StatefulWidget { /// A resident client to issue requests against the APIs. - late final Pay _payClient; + final Pay _payClient; /// Specifies the payment provider supported by the button final PayProvider buttonProvider; /// A function called when the payment process yields a result. - final void Function(Map result)? onPaymentResult; + final PaymentResultCallback? paymentCallback; final double width; final double height; @@ -56,7 +58,7 @@ abstract class PayButton extends StatefulWidget { super.key, required this.buttonProvider, required final PaymentConfiguration paymentConfiguration, - this.onPaymentResult, + this.paymentCallback, this.width = 0, this.height = 0, this.margin = const EdgeInsets.all(0), @@ -77,7 +79,7 @@ abstract class PayButton extends StatefulWidget { try { final result = await _payClient.showPaymentSelector(buttonProvider, paymentItems); - onPaymentResult?.call(result); + paymentCallback?.call(result); } catch (error) { onError?.call(error); } diff --git a/pay_ios/ios/Classes/PayPlugin.swift b/pay_ios/ios/Classes/PayPlugin.swift index 51da04c1..2b8500d3 100644 --- a/pay_ios/ios/Classes/PayPlugin.swift +++ b/pay_ios/ios/Classes/PayPlugin.swift @@ -23,6 +23,7 @@ public class PayPlugin: NSObject, FlutterPlugin { private static let methodChannelName = "plugins.flutter.io/pay_channel" private let methodUserCanPay = "userCanPay" private let methodShowPaymentSelector = "showPaymentSelector" + private let methodUpdatePaymentResult = "updatePaymentResult" private let paymentHandler = PaymentHandler() @@ -48,6 +49,10 @@ public class PayPlugin: NSObject, FlutterPlugin { paymentConfiguration: arguments["payment_profile"] as! String, paymentItems: arguments["payment_items"] as! [[String: Any?]]) + case methodUpdatePaymentResult: + let isSuccess = call.arguments as! Bool + paymentHandler.updatePaymentResult(isSuccess: isSuccess) + default: result(FlutterMethodNotImplemented) } diff --git a/pay_ios/ios/Classes/PaymentHandler.swift b/pay_ios/ios/Classes/PaymentHandler.swift index 887aadf0..ea191378 100644 --- a/pay_ios/ios/Classes/PaymentHandler.swift +++ b/pay_ios/ios/Classes/PaymentHandler.swift @@ -36,6 +36,9 @@ enum PaymentHandlerStatus { /// paymentHandler.canMakePayments(stringArguments) /// ``` class PaymentHandler: NSObject { + + /// Holds the completion handler so it can be updated later on from the Flutter side. + var completionHandler: PaymentCompletionHandler? /// Holds the current status of the payment process. var paymentHandlerStatus: PaymentHandlerStatus! @@ -106,7 +109,7 @@ class PaymentHandler: NSObject { /// /// - parameter paymentConfigurationString: A JSON string with the configuration to execute /// this payment. - /// - returns: A list of recognized networks supported for this operation. + /// - returns: A list of recognized networks supported for this operation. private static func supportedNetworks(from paymentConfigurationString: String) -> [PKPaymentNetwork]? { guard let paymentConfiguration = extractPaymentConfiguration(from: paymentConfigurationString) else { return nil @@ -169,6 +172,16 @@ class PaymentHandler: NSObject { return paymentRequest } + + /// Updates the payment result based on the value you pass to the `isSuccess` parameter. + /// + /// - parameter isSuccess: A boolean that determines whether the payment was successful or not. + /// - returns: nothing. + func updatePaymentResult(isSuccess: Bool) { + // Call completion handler with the given success status. + completionHandler?(isSuccess) + completionHandler = nil + } } /// Extension that implements the completion methods in the delegate to respond to user selection. @@ -179,7 +192,6 @@ extension PaymentHandler: PKPaymentAuthorizationControllerDelegate { } func paymentAuthorizationController(_: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) { - // Collect payment result or error and return if no payment was selected guard let paymentResultData = try? JSONSerialization.data(withJSONObject: payment.toDictionary()) else { self.paymentResult(FlutterError(code: "paymentResultDeserializationFailed", message: nil, details: nil)) @@ -189,8 +201,13 @@ extension PaymentHandler: PKPaymentAuthorizationControllerDelegate { // Return the result back to the channel self.paymentResult(String(decoding: paymentResultData, as: UTF8.self)) + // Store completion handler + completionHandler = { isSuccess in + let status: PKPaymentAuthorizationStatus = isSuccess ? .success : .failure + completion(PKPaymentAuthorizationResult(status: status, errors: nil)) + } + paymentHandlerStatus = .authorized - completion(PKPaymentAuthorizationResult(status: PKPaymentAuthorizationStatus.success, errors: nil)) } func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { diff --git a/pay_ios/lib/pay_ios.dart b/pay_ios/lib/pay_ios.dart index 487252c6..6f4b324f 100644 --- a/pay_ios/lib/pay_ios.dart +++ b/pay_ios/lib/pay_ios.dart @@ -16,4 +16,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +export 'src/ios_pay_channel.dart'; + part 'src/widgets/apple_pay_button.dart'; diff --git a/pay_ios/lib/src/ios_pay_channel.dart b/pay_ios/lib/src/ios_pay_channel.dart new file mode 100644 index 00000000..320e9be9 --- /dev/null +++ b/pay_ios/lib/src/ios_pay_channel.dart @@ -0,0 +1,13 @@ +/// Copyright 2023 Google LLC. +/// SPDX-License-Identifier: Apache-2.0 +library; + +import 'package:pay_platform_interface/pay_channel.dart'; + +/// This implements the iOS specific functionality of the Pay plugin. +class IosPayMethodChannel extends PayMethodChannel { + /// Update the payment result with the native platform. + Future updatePaymentResult(bool isSuccess) async { + return channel.invokeMethod('updatePaymentResult', isSuccess); + } +} diff --git a/pay_ios/test/src/ios_pay_channel_test.dart b/pay_ios/test/src/ios_pay_channel_test.dart new file mode 100644 index 00000000..f0d57257 --- /dev/null +++ b/pay_ios/test/src/ios_pay_channel_test.dart @@ -0,0 +1,68 @@ +/// Copyright 2023 Google LLC +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// https://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +library; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pay_ios/pay_ios.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late final IosPayMethodChannel payChannel; + + final defaultBinaryMessenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUpAll(() async { + payChannel = IosPayMethodChannel(); + }); + + group('Verify channel I/O for', () { + final log = []; + const testResponses = { + 'updatePaymentResult': null, + }; + + setUp(() { + defaultBinaryMessenger.setMockMethodCallHandler( + payChannel.channel, + (MethodCall methodCall) async { + log.add(methodCall); + final response = testResponses[methodCall.method]; + if (response is Exception) { + return Future.error(response); + } + return Future.value(response); + }, + ); + }); + + test('updatePaymentResult', () async { + await payChannel.updatePaymentResult(true); + expect( + log, + [isMethodCall('updatePaymentResult', arguments: true)], + ); + }); + + tearDown(() async { + defaultBinaryMessenger.setMockMethodCallHandler( + payChannel.channel, + null, + ); + log.clear(); + }); + }); +} diff --git a/pay_platform_interface/lib/pay_channel.dart b/pay_platform_interface/lib/pay_channel.dart index 0a15a217..93d00b84 100644 --- a/pay_platform_interface/lib/pay_channel.dart +++ b/pay_platform_interface/lib/pay_channel.dart @@ -34,7 +34,7 @@ import 'pay_platform_interface.dart'; /// ``` class PayMethodChannel extends PayPlatform { // The channel used to send messages down the native pipe. - final MethodChannel _channel = + final MethodChannel channel = const MethodChannel('plugins.flutter.io/pay_channel'); /// Determines whether a user can pay with the provider in the configuration. @@ -43,7 +43,7 @@ class PayMethodChannel extends PayPlatform { /// returns a boolean for the [paymentConfiguration] specified. @override Future userCanPay(PaymentConfiguration paymentConfiguration) async { - return await _channel.invokeMethod( + return await channel.invokeMethod( 'userCanPay', jsonEncode(await paymentConfiguration.parameterMap())) as bool; } @@ -59,7 +59,7 @@ class PayMethodChannel extends PayPlatform { PaymentConfiguration paymentConfiguration, List paymentItems, ) async { - final paymentResult = await _channel.invokeMethod('showPaymentSelector', { + final paymentResult = await channel.invokeMethod('showPaymentSelector', { 'payment_profile': jsonEncode(await paymentConfiguration.parameterMap()), 'payment_items': paymentItems.map((item) => item.toMap()).toList(), }) as String; diff --git a/pay_platform_interface/test/pay_channel_test.dart b/pay_platform_interface/test/pay_channel_test.dart index 32002c0c..0c52144c 100644 --- a/pay_platform_interface/test/pay_channel_test.dart +++ b/pay_platform_interface/test/pay_channel_test.dart @@ -17,7 +17,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pay_platform_interface/core/payment_configuration.dart'; - import 'package:pay_platform_interface/pay_channel.dart'; void main() { @@ -25,6 +24,8 @@ void main() { late final PayMethodChannel mobilePlatform; const channel = MethodChannel('plugins.flutter.io/pay_channel'); + final defaultBinaryMessenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; const providerApplePay = PayProvider.apple_pay; final payConfigString = @@ -43,9 +44,8 @@ void main() { }; setUp(() { - TestWidgetsFlutterBinding.ensureInitialized(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + defaultBinaryMessenger.setMockMethodCallHandler(channel, + (MethodCall methodCall) async { log.add(methodCall); final response = testResponses[methodCall.method]; if (response is Exception) { @@ -77,8 +77,7 @@ void main() { }); tearDown(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); + defaultBinaryMessenger.setMockMethodCallHandler(channel, null); log.clear(); }); });