Skip to content
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

➕ Add the success/failure handling from Dart side for Apple Pay #200

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a3b1b81
add the success/failure handling from dart for apple pay
YazeedAlKhalaf May 3, 2023
78dfe7b
enhance the example app Pay usage
YazeedAlKhalaf Jun 15, 2023
fcab163
move impl. of updatePaymentStatus to pay_ios and test it
YazeedAlKhalaf Aug 2, 2023
0d8c189
update the example app to showcase the new feature
YazeedAlKhalaf Aug 2, 2023
23b63f8
fix deprecated thing in a test
YazeedAlKhalaf Aug 2, 2023
cddc099
try to match the styling of the other fellow files
YazeedAlKhalaf Aug 2, 2023
1323120
remove dark theme from demo app
YazeedAlKhalaf Dec 20, 2023
4a48cc8
rename IOS to Ios in IOSPayMethodChannel
YazeedAlKhalaf Dec 20, 2023
a06e1ec
rename update payment status to update payment result
YazeedAlKhalaf Dec 20, 2023
cd07e7c
add docs for updatePaymentResult
YazeedAlKhalaf Dec 20, 2023
87268b8
ran the project using xcode 15
YazeedAlKhalaf Dec 20, 2023
8e8e194
make the demo app show a regular integration path
YazeedAlKhalaf Dec 20, 2023
8d236bb
move the defaultBinaryMessenger for test calling to a variable to inc…
YazeedAlKhalaf Jun 1, 2024
122d7ba
move completion handler after serialization is successful
YazeedAlKhalaf Jun 1, 2024
b09fd8c
enhance tests and their readability
YazeedAlKhalaf Jun 1, 2024
73fa61c
make updatePaymentResult throw an exception instead of failing silently
YazeedAlKhalaf Jun 1, 2024
747ff50
Merge branch 'main' of github.com:google-pay/flutter-plugin into add-…
YazeedAlKhalaf Jun 1, 2024
b39bddd
fix the linter complaining
YazeedAlKhalaf Jun 1, 2024
15fb1aa
just ran the app with flutter 3.22.1
YazeedAlKhalaf Jun 1, 2024
7b64e53
make the implementation better, thanks to @JlUgia
YazeedAlKhalaf Jun 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions pay/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/pay_ios/ios"

SPEC CHECKSUMS:
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
integration_test: 13825b8a9334a850581300559b8839134b124670
pay_ios: 8c7beb9c61d885f3f51b61f75f8793023fc8843a

PODFILE CHECKSUM: fc81e398f362bae88bdf55239bd5cf842faad39f
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3

COCOAPODS: 1.11.3
COCOAPODS: 1.12.1
2 changes: 2 additions & 0 deletions pay/example/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,7 @@
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
<true/>
</dict>
</plist>
69 changes: 52 additions & 17 deletions pay/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
/// See the License for the specific language governing permissions and
/// limitations under the License.

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:pay/pay.dart';
Expand Down Expand Up @@ -44,6 +45,8 @@ class PayMaterialApp extends StatelessWidget {
const Locale('es', ''),
const Locale('de', ''),
],
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
home: PaySampleApp(),
);
}
Expand All @@ -57,21 +60,32 @@ class PaySampleApp extends StatefulWidget {
}

class _PaySampleAppState extends State<PaySampleApp> {
final _applePayConfig = PaymentConfiguration.fromJsonString(
payment_configurations.defaultApplePay,
);
late final Future<PaymentConfiguration> _googlePayConfigFuture;
late Pay _pay;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you probably noted, this plugin offers two integration paths.

  1. Most of the business logic, including the pay client, is contained within the button components (eg.: ApplePayButton): This approach is great for folks who are happy with a default-based configuration and prefer to think of their apps as a collection of components.
  2. The views and business logic are separate and the implementer is responsible for handling both separately: Using the Pay client directly to issue calls, and showing the raw button components in the UI when necessary.

To be consistent with that, we'd want to update the StatefulWidget under pay/lib/src/widgets/apple_pay_button.dart to handle the new logic too, and use it in the example.
One way to do that would be to offer an additional result callback in the Apple Pay button widget in the pay library that includes a handler (eg.: paymentConfirmation) that lets folks update the payment result to the iOS native end.

Let me know what you think

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I didn't notice the the two integration paths before 😅.

Regarding the addition of a new result callback, since we renamed the method updatePaymentStatus to updatePaymentResult, I think it will be confusing to users who see onPaymentResult and might link it to updatePaymentResult.

We can do a breaking a change to the API and make the onPaymentResult signature:

final bool Function(Map<String, dynamic> result) onPaymentResult;

And then the user can do their backend call here and return the result of the payment from this callback, which is required.

We can also make the return type of the callback nullable so existing users of the package who are not interested don't need to touch their code, but then there becomes two definitions of success, null or true. null would be the default state if the user didn't return anything.

What do you think? Should we introduce a new callback, or use the existing one?

I think using onPaymentResult makes more sense here since the names are related and we can do something like this in the PayButton:

  /// Callback function to respond to tap events.
  ///
  /// This is the default function for tap events. Calls the [onPressed]
  /// function if set, and initiates the payment process with the [paymentItems]
  /// specified.
  VoidCallback _defaultOnPressed(
    VoidCallback? onPressed,
    List<PaymentItem> paymentItems,
  ) {
    return () async {
      onPressed?.call();
      try {
        final result = await _payClient.showPaymentSelector(
          buttonProvider,
          paymentItems,
        );

        final isPaymentSuccessful = onPaymentResult(result);
        if (_supportedPlatforms.contains(TargetPlatform.iOS)) {
          await _payClient.updatePaymentResult(isPaymentSuccessful ?? true);
        }
      } catch (error) {
        onError?.call(error);
      }
    };
  }

and then we can keep the Google button signature the same, but have it return null for example when providing it to the super.

and for Apple button, we can expose the new signature for users.

What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the proposal @YazeedAlKhalaf.
Your suggestion certainly supposed the least resistance path into getting the functionality out. I see three main disadvantages:

  • It's a breaking change, although minor and quick to address.
  • Using the return value of a function to trigger an additional action reduces code clarity, as it requires developers to read the docs to know what's happening for certain.
  • Changing the signature of the base implementation to accommodate for one specific platform (child class) increases the tension between the implementations a little, forcing Google Pay and any other provider that uses the base implementation to adhere to it.

Using an additional callback for Apple Pay doesn't have any of the disadvantages above, although this approach also has some drawbacks:

  • The internal logic is a little more intricate.
  • Adding a second callback to the Apple Pay button implementation requires demoting the original callback in the base class to a late final, since it now needs to be overriden in the Apple button child class to call both callbacks if set. This can't be done in the initializer block because the second callback needs the Pay client to talk to the iOS native end, which is a class member. A way to circumvent that would be to create a second Pay client for that call only. To me, the late final demotion seems more appropriate.

I've put together a prototypical proposal that adds an extra callback for Apple Pay and lets developers choose the one that is best suited for them (the change may seem a little larger than necessary, since it includes a couple of type definitions and extra modifiers that can be now added). This is how the change would look for those who decide to use the confirm flow:

onApplePayResultWithConfirm(
    Map<String, dynamic> paymentResult, PaymentConfirmation handler) {
  // Complete the payment
  bool paymentCompletionResult = ...;
  handler.confirmPayment(paymentCompletionResult);
}

ApplePayButton(
  paymentConfiguration: PaymentConfiguration.fromJsonString(
    payment_configurations.defaultApplePay),
  paymentItems: _paymentItems,
  onPaymentResultWithConfirm: onApplePayResultWithConfirm,
)

I have a mild preference for the latter approach, and would like to hear your thoughts on it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great tbh, easier for the developers 👍.

I'll try to bundle it in this PR and hopefully we can see what to do next!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much @JlUgia for your suggestion and PR!

I have implemented your changes with a small change, can you please look at it?


bool shouldPaymentSucceedOnIos = true;

@override
void initState() {
super.initState();
_googlePayConfigFuture =
PaymentConfiguration.fromAsset('default_google_pay_config.json');
_googlePayConfigFuture = PaymentConfiguration.fromAsset(
'default_google_pay_config.json',
);
_pay = Pay({
PayProvider.apple_pay: _applePayConfig,
});
}

void onGooglePayResult(paymentResult) {
debugPrint(paymentResult.toString());
}

void onApplePayResult(paymentResult) {
void onApplePayResult(paymentResult) async {
debugPrint(paymentResult.toString());
await _pay.updatePaymentStatus(shouldPaymentSucceedOnIos);
}

@override
Expand Down Expand Up @@ -126,23 +140,25 @@ class _PaySampleAppState extends State<PaySampleApp> {
),
// Example pay button configured using an asset
FutureBuilder<PaymentConfiguration>(
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,
Expand All @@ -152,6 +168,25 @@ class _PaySampleAppState extends State<PaySampleApp> {
child: CircularProgressIndicator(),
),
),
// This allows you to control whether the payment shall succeed or not.
// This is only available on iOS.
// Usually you would want to wait for your backend to return a value,
// before you update the payment status.
if (defaultTargetPlatform == TargetPlatform.iOS)
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Should the payment succeed?'),
Switch.adaptive(
value: shouldPaymentSucceedOnIos,
onChanged: (value) {
setState(() {
shouldPaymentSucceedOnIos = value;
});
},
),
],
),
const SizedBox(height: 15)
],
),
Expand Down
19 changes: 17 additions & 2 deletions pay/lib/src/pay.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ 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();

/// Alternative constructor to create a [Pay] object with a list of
/// configurations in [String] format.
@Deprecated(
'Prefer to use [Pay({ [PayProvider]: [PaymentConfiguration] })]. Take a look at the readme to see examples')
Pay.withAssets(List<String> configAssets)
: _payPlatform = PayMethodChannel() {
: _payPlatform = defaultTargetPlatform == TargetPlatform.iOS
? IOSPayMethodChannel()
: PayMethodChannel() {
_assetInitializationFuture = _loadConfigAssets(configAssets);
}

Expand Down Expand Up @@ -76,6 +81,16 @@ class Pay {
_configurations![provider]!, paymentItems);
}

/// Update the payment status with the native platform.
/// Works only on iOS.
Future<void> updatePaymentStatus(bool isSuccess) async {
if (_payPlatform is IOSPayMethodChannel) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will fail silently if executed from Android. Since this can only happen at development time I'd let the Error surface back for the developer to fix their code.
Alternatively, and if you think the error is not informative enough, we can craft our own exception/error.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, i think making a custom exception would be better in this case. can you check it?

await _assetInitializationFuture;
final iosPayPlatform = _payPlatform as IOSPayMethodChannel;
return iosPayPlatform.updatePaymentStatus(isSuccess);
}
}

/// Verifies that the selected provider has been previously configured or
/// throws otherwise.
Future throwIfProviderIsNotDefined(PayProvider provider) async {
Expand Down
5 changes: 5 additions & 0 deletions pay_ios/ios/Classes/PayPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 methodUpdatePaymentStatus = "updatePaymentStatus"

private let paymentHandler = PaymentHandler()

Expand All @@ -48,6 +49,10 @@ public class PayPlugin: NSObject, FlutterPlugin {
paymentConfiguration: arguments["payment_profile"] as! String,
paymentItems: arguments["payment_items"] as! [[String: Any?]])

case methodUpdatePaymentStatus:
let isSuccess = call.arguments as! Bool
paymentHandler.updatePaymentStatus(isSuccess: isSuccess)

default:
result(FlutterMethodNotImplemented)
}
Expand Down
15 changes: 14 additions & 1 deletion pay_ios/ios/Classes/PaymentHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -169,6 +172,12 @@ class PaymentHandler: NSObject {

return paymentRequest
}

YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
func updatePaymentStatus(isSuccess: Bool) {
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
// 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.
Expand All @@ -179,6 +188,11 @@ extension PaymentHandler: PKPaymentAuthorizationControllerDelegate {
}

func paymentAuthorizationController(_: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, handler completion: @escaping (PKPaymentAuthorizationResult) -> Void) {
// Store completion handler
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
completionHandler = { isSuccess in
let status: PKPaymentAuthorizationStatus = isSuccess ? .success : .failure
completion(PKPaymentAuthorizationResult(status: status, errors: nil))
}

// Collect payment result or error and return if no payment was selected
guard let paymentResultData = try? JSONSerialization.data(withJSONObject: payment.toDictionary()) else {
Expand All @@ -190,7 +204,6 @@ extension PaymentHandler: PKPaymentAuthorizationControllerDelegate {
self.paymentResult(String(decoding: paymentResultData, as: UTF8.self))

paymentHandlerStatus = .authorized
completion(PKPaymentAuthorizationResult(status: PKPaymentAuthorizationStatus.success, errors: nil))
}

func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
Expand Down
2 changes: 2 additions & 0 deletions pay_ios/lib/pay_ios.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,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';
12 changes: 12 additions & 0 deletions pay_ios/lib/src/ios_pay_channel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// Copyright 2023 Google LLC.
/// SPDX-License-Identifier: Apache-2.0

import 'package:pay_platform_interface/pay_channel.dart';

/// This implements the iOS specific functionality of the Pay plugin.
class IOSPayMethodChannel extends PayMethodChannel {
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
/// Update the payment status with the native platform.
Future<void> updatePaymentStatus(bool isSuccess) async {
return channel.invokeMethod('updatePaymentStatus', isSuccess);
}
}
57 changes: 57 additions & 0 deletions pay_ios/test/src/ios_pay_channel_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/// 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.

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;

setUpAll(() async {
_payChannel = IOSPayMethodChannel();
});

group('Verify channel I/O for', () {
final log = <MethodCall>[];
const testResponses = <String, Object?>{
'updatePaymentStatus': null,
};

setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
_payChannel.channel,
(MethodCall methodCall) async {
log.add(methodCall);
final response = testResponses[methodCall.method];
if (response is Exception) {
return Future<Object?>.error(response);
}
return Future<Object?>.value(response);
},
);
});

test('updatePaymentStatus', () async {
await _payChannel.updatePaymentStatus(true);
expect(
log,
<Matcher>[isMethodCall('updatePaymentStatus', arguments: true)],
);
});
});
}
6 changes: 3 additions & 3 deletions pay_platform_interface/lib/pay_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -43,7 +43,7 @@ class PayMethodChannel extends PayPlatform {
/// returns a boolean for the [paymentConfiguration] specified.
@override
Future<bool> userCanPay(PaymentConfiguration paymentConfiguration) async {
return await _channel.invokeMethod(
return await channel.invokeMethod(
'userCanPay', jsonEncode(await paymentConfiguration.parameterMap()));
}

Expand All @@ -58,7 +58,7 @@ class PayMethodChannel extends PayPlatform {
PaymentConfiguration paymentConfiguration,
List<PaymentItem> 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(),
});
Expand Down
25 changes: 14 additions & 11 deletions pay_platform_interface/test/pay_channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,12 @@
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() {
TestWidgetsFlutterBinding.ensureInitialized();

late final PayMethodChannel _mobilePlatform;
const channel = MethodChannel('plugins.flutter.io/pay_channel');

const _providerApplePay = PayProvider.apple_pay;
final _payConfigString =
Expand All @@ -43,14 +41,18 @@ void main() {
};

setUp(() {
channel.setMockMethodCallHandler((MethodCall methodCall) async {
log.add(methodCall);
final response = testResponses[methodCall.method];
if (response is Exception) {
return Future<Object>.error(response);
}
return Future<Object>.value(response);
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
YazeedAlKhalaf marked this conversation as resolved.
Show resolved Hide resolved
.setMockMethodCallHandler(
_mobilePlatform.channel,
(MethodCall methodCall) async {
log.add(methodCall);
final response = testResponses[methodCall.method];
if (response is Exception) {
return Future<Object>.error(response);
}
return Future<Object>.value(response);
},
);
});

test('userCanPay', () async {
Expand All @@ -75,7 +77,8 @@ void main() {
});

tearDown(() async {
channel.setMockMethodCallHandler(null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_mobilePlatform.channel, null);
log.clear();
});
});
Expand Down