Flutter - 支払いの受付
Omiseを使用してFlutterアプリケーションで支払いを受け付ける方法を学びます。このガイドでは、iOS/Androidのネイティブパフォーマンスを備えたクロスプラットフォーム支払い統合について説明します。
概要
FlutterアプリケーションにOmise支払いを統合すると、プラットフォームチャネルを経由してネイティブSDKパフォーマンスを持つiOS/Androidの統一されたコードベースが提供されます。
主な機能
- クロスプラットフォーム - iOS/Android用の単一のDartコードベース
- ネイティブパフォーマンス - プラットフォームチャネル経由でネイティブSDKを活用
- 型安全性 - Dartの強い型付けで信頼性を確保
- ホットリロード - Flutterのホットリロードで高速開発
- Material & Cupertino - プラットフォーム固有のUIコンポーネント
- Null Safety - モダンなDartのNull Safety対応
前提条件
支払い受付を実装する前に:
- Flutter SDK 3.0以上がインストールされていること
- ネイティブiOS/Android開発環境
- 請求作成用のバックエンドAPI
- API キー付きのOmiseアカウント
インストール
依存関係の追加
pubspec.yaml に以下を追加します:
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
webview_flutter: ^4.4.0
url_launcher: ^6.2.0
dev_dependencies:
flutter_test:
sdk: flutter
プラットフォームチャネルの設定
ネイティブSDK統合用のプラットフォームチャネルを作成します:
// lib/services/omise_service.dart
import 'package:flutter/services.dart';
class OmiseService {
static const MethodChannel _channel = MethodChannel('omise_payment');
final String publicKey;
OmiseService({required this.publicKey});
Future<OmiseToken> createToken({
required String cardNumber,
required String cardholderName,
required int expiryMonth,
required int expiryYear,
required String cvv,
}) async {
try {
final result = await _channel.invokeMethod('createToken', {
'publicKey': publicKey,
'cardNumber': cardNumber,
'cardholderName': cardholderName,
'expiryMonth': expiryMonth,
'expiryYear': expiryYear,
'cvv': cvv,
});
return OmiseToken.fromJson(result);
} on PlatformException catch (e) {
throw OmiseException(
message: e.message ?? 'Failed to create token',
code: e.code,
);
}
}
}
iOSネイティブ実装
ios/Runner/OmisePlugin.swift を作成します:
import Flutter
import OmiseSDK
class OmisePlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "omise_payment",
binaryMessenger: registrar.messenger()
)
let instance = OmisePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "createToken":
createToken(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func createToken(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let publicKey = args["publicKey"] as? String,
let cardNumber = args["cardNumber"] as? String,
let cardholderName = args["cardholderName"] as? String,
let expiryMonth = args["expiryMonth"] as? Int,
let expiryYear = args["expiryYear"] as? Int,
let cvv = args["cvv"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
let client = OmiseSDKClient(publicKey: publicKey)
let request = OmiseTokenRequest(
name: cardholderName,
number: cardNumber,
expirationMonth: expiryMonth,
expirationYear: expiryYear,
securityCode: cvv
)
client.send(request) { tokenResult in
switch tokenResult {
case .success(let token):
result([
"id": token.id,
"object": token.object,
"livemode": token.livemode,
])
case .failure(let error):
result(FlutterError(
code: "TOKEN_ERROR",
message: error.localizedDescription,
details: nil
))
}
}
}
}
Androidネイティブ実装
android/app/src/main/kotlin/OmisePlugin.kt を作成します:
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import co.omise.android.OmiseClient
import co.omise.android.models.CardParam
import co.omise.android.models.Token
class OmisePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "omise_payment")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"createToken" -> createToken(call, result)
else -> result.notImplemented()
}
}
private fun createToken(call: MethodCall, result: MethodChannel.Result) {
val publicKey = call.argument<String>("publicKey") ?: run {
result.error("INVALID_ARGS", "Missing public key", null)
return
}
val cardNumber = call.argument<String>("cardNumber") ?: ""
val cardholderName = call.argument<String>("cardholderName") ?: ""
val expiryMonth = call.argument<Int>("expiryMonth") ?: 0
val expiryYear = call.argument<Int>("expiryYear") ?: 0
val cvv = call.argument<String>("cvv") ?: ""
val client = OmiseClient(publicKey)
val cardParam = CardParam(
name = cardholderName,
number = cardNumber,
expirationMonth = expiryMonth,
expirationYear = expiryYear,
securityCode = cvv
)
client.send(cardParam, object : RequestListener<Token> {
override fun onRequestSucceed(token: Token) {
result.success(mapOf(
"id" to token.id,
"object" to token.`object`,
"livemode" to token.livemode
))
}
override fun onRequestFailed(throwable: Throwable) {
result.error(
"TOKEN_ERROR",
throwable.message ?: "Failed to create token",
null
)
}
})
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
支払いフロー実装
支払いモデル
// lib/models/omise_token.dart
class OmiseToken {
final String id;
final String object;
final bool livemode;
OmiseToken({
required this.id,
required this.object,
required this.livemode,
});
factory OmiseToken.fromJson(Map<String, dynamic> json) {
return OmiseToken(
id: json['id'] as String,
object: json['object'] as String,
livemode: json['livemode'] as bool,
);
}
}
// lib/models/charge.dart
class Charge {
final String id;
final String status;
final int amount;
final String currency;
final String? authorizeUri;
Charge({
required this.id,
required this.status,
required this.amount,
required this.currency,
this.authorizeUri,
});
factory Charge.fromJson(Map<String, dynamic> json) {
return Charge(
id: json['id'] as String,
status: json['status'] as String,
amount: json['amount'] as int,
currency: json['currency'] as String,
authorizeUri: json['authorize_uri'] as String?,
);
}
bool get requiresAuthorization => authorizeUri != null;
bool get isSuccessful => status == 'successful';
}
// lib/models/omise_exception.dart
class OmiseException implements Exception {
final String message;
final String? code;
final dynamic details;
OmiseException({
required this.message,
this.code,
this.details,
});
@override
String toString() => 'OmiseException: $message (code: $code)';
}
支払いフォームウィジェット
// lib/widgets/payment_form.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class PaymentForm extends StatefulWidget {
final int amount;
final String currency;
final Function(String tokenId) onSubmit;
const PaymentForm({
Key? key,
required this.amount,
required this.currency,
required this.onSubmit,
}) : super(key: key);
@override
State<PaymentForm> createState() => _PaymentFormState();
}
class _PaymentFormState extends State<PaymentForm> {
final _formKey = GlobalKey<FormState>();
final _cardNumberController = TextEditingController();
final _cardholderController = TextEditingController();
final _expiryController = TextEditingController();
final _cvvController = TextEditingController();
bool _isProcessing = false;
@override
void dispose() {
_cardNumberController.dispose();
_cardholderController.dispose();
_expiryController.dispose();
_cvvController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _isProcessing = true);
try {
final expiry = _parseExpiry(_expiryController.text);
final omiseService = OmiseService(publicKey: 'pkey_test_123');
final token = await omiseService.createToken(
cardNumber: _cardNumberController.text.replaceAll(' ', ''),
cardholderName: _cardholderController.text,
expiryMonth: expiry.month,
expiryYear: expiry.year,
cvv: _cvvController.text,
);
widget.onSubmit(token.id);
} catch (e) {
_showError(e.toString());
} finally {
setState(() => _isProcessing = false);
}
}
({int month, int year}) _parseExpiry(String text) {
final parts = text.split('/');
return (
month: int.parse(parts[0].trim()),
year: 2000 + int.parse(parts[1].trim()),
);
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _cardNumberController,
decoration: const InputDecoration(
labelText: 'Card Number',
hintText: '1234 5678 9012 3456',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
CardNumberFormatter(),
LengthLimitingTextInputFormatter(19),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Card number is required';
}
if (value.replaceAll(' ', '').length < 13) {
return 'Invalid card number';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _cardholderController,
decoration: const InputDecoration(
labelText: 'Cardholder Name',
hintText: 'JOHN APPLESEED',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Cardholder name is required';
}
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _expiryController,
decoration: const InputDecoration(
labelText: 'Expiry',
hintText: 'MM/YY',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
ExpiryDateFormatter(),
LengthLimitingTextInputFormatter(5),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Expiry is required';
}
if (!RegExp(r'^\d{2}/\d{2}$').hasMatch(value)) {
return 'Invalid format';
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _cvvController,
decoration: const InputDecoration(
labelText: 'CVV',
hintText: '123',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'CVV is required';
}
if (value.length < 3) {
return 'Invalid CVV';
}
return null;
},
),
),
],
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isProcessing ? null : _handleSubmit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: Colors.green,
),
child: _isProcessing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
'Pay ${widget.currency} ${(widget.amount / 100).toStringAsFixed(2)}',
style: const TextStyle(fontSize: 18),
),
),
],
),
);
}
}