Flutter - Accept Payments
Learn how to accept payments in your Flutter application using Omise. This guide covers cross-platform payment integration with native performance for both iOS and Android.
Overviewโ
Integrating Omise payments in Flutter applications provides a unified codebase for iOS and Android with native SDK performance through platform channels.
Key Featuresโ
- Cross-Platform - Single Dart codebase for iOS and Android
- Native Performance - Leverages native SDKs via platform channels
- Type-Safe - Dart's strong typing for reliability
- Hot Reload - Fast development with Flutter's hot reload
- Material & Cupertino - Platform-specific UI components
- Null Safety - Modern Dart with null safety
Prerequisitesโ
Before implementing payment acceptance:
- Flutter SDK 3.0 or higher installed
- Native iOS and Android development environments
- Backend API for charge creation
- Omise account with API keys
Installationโ
Add Dependenciesโ
Add to 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
Platform Channel Setupโ
Create platform channels for native SDK integration:
// 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 Native Implementationโ
Create 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 Native Implementationโ
Create 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)
}
}
Payment Flow Implementationโ
Payment Modelsโ
// 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)';
}
Payment Form Widgetโ
// 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),
),
),
],
),
);
}
}
Input Formattersโ
// lib/utils/formatters.dart
import 'package:flutter/services.dart';
class CardNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.replaceAll(' ', '');
final buffer = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (i > 0 && i % 4 == 0) {
buffer.write(' ');
}
buffer.write(text[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
class ExpiryDateFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.replaceAll('/', '');
if (text.length >= 2) {
return TextEditingValue(
text: '${text.substring(0, 2)}/${text.substring(2)}',
selection: TextSelection.collapsed(
offset: text.length >= 2 ? text.length + 1 : text.length,
),
);
}
return newValue;
}
}
3D Secure Implementationโ
3D Secure WebView Screenโ
// lib/screens/three_ds_screen.dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ThreeDSecureScreen extends StatefulWidget {
final String authorizeUri;
final String returnUri;
const ThreeDSecureScreen({
Key? key,
required this.authorizeUri,
required this.returnUri,
}) : super(key: key);
@override
State<ThreeDSecureScreen> createState() => _ThreeDSecureScreenState();
}
class _ThreeDSecureScreenState extends State<ThreeDSecureScreen> {
late final WebViewController _controller;
bool _isLoading = true;
String? _errorMessage;
Timer? _timeoutTimer;
static const Duration _timeoutDuration = Duration(minutes: 5);
@override
void initState() {
super.initState();
_initializeController();
_startTimeout();
}
@override
void dispose() {
_timeoutTimer?.cancel();
super.dispose();
}
void _startTimeout() {
// Cancel previous timer if exists
_timeoutTimer?.cancel();
// Start new timeout timer
_timeoutTimer = Timer(_timeoutDuration, () {
if (mounted) {
setState(() {
_errorMessage = 'Authentication timeout. Please try again.';
_isLoading = false;
});
// Optionally pop with timeout result
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
Navigator.of(context).pop(false);
}
});
}
});
}
void _initializeController() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
setState(() {
_isLoading = true;
_errorMessage = null;
});
// Reset timeout on new page
_startTimeout();
},
onPageFinished: (url) {
setState(() => _isLoading = false);
},
onNavigationRequest: (request) {
if (request.url.startsWith(widget.returnUri)) {
_timeoutTimer?.cancel();
Navigator.of(context).pop(true);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (error) {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to load authentication page: ${error.description}';
});
},
),
)
..loadRequest(Uri.parse(widget.authorizeUri))
.catchError((error) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to load URL: $error';
});
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Secure Authentication'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_timeoutTimer?.cancel();
Navigator.of(context).pop(false);
},
),
),
body: Stack(
children: [
if (_errorMessage == null)
WebViewWidget(controller: _controller)
else
Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
setState(() {
_errorMessage = null;
_isLoading = true;
});
_initializeController();
},
child: const Text('Retry'),
),
],
),
),
),
if (_isLoading && _errorMessage == null)
const Center(
child: CircularProgressIndicator(),
),
],
),
);
}
}
// Don't forget to add Timer import at the top:
// import 'dart:async';
Payment Processing with 3DSโ
// lib/services/payment_service.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class PaymentService {
final String baseUrl;
PaymentService({required this.baseUrl});
Future<Charge> createCharge({
required String token,
required int amount,
required String currency,
String returnUri = 'myapp://payment/complete',
Map<String, dynamic>? metadata,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/charges'),
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'token': token,
'amount': amount,
'currency': currency,
'return_uri': returnUri,
if (metadata != null) 'metadata': metadata,
}),
);
if (response.statusCode != 200) {
throw OmiseException(
message: 'Failed to create charge',
code: 'CHARGE_ERROR',
);
}
return Charge.fromJson(jsonDecode(response.body));
}
Future<bool> handle3DSecure(
BuildContext context,
String authorizeUri,
) async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ThreeDSecureScreen(
authorizeUri: authorizeUri,
returnUri: 'myapp://payment/complete',
),
fullscreenDialog: true,
),
);
return result ?? false;
}
Future<Charge> verifyCharge(String chargeId) async {
final response = await http.get(
Uri.parse('$baseUrl/charges/$chargeId'),
);
if (response.statusCode != 200) {
throw OmiseException(
message: 'Failed to verify charge',
code: 'VERIFICATION_ERROR',
);
}
return Charge.fromJson(jsonDecode(response.body));
}
}
Complete Checkout Flowโ
// lib/screens/checkout_screen.dart
import 'package:flutter/material.dart';
class CheckoutScreen extends StatefulWidget {
final int amount;
final String currency;
const CheckoutScreen({
Key? key,
required this.amount,
required this.currency,
}) : super(key: key);
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
final _paymentService = PaymentService(baseUrl: 'https://api.yourapp.com');
Future<void> _handlePayment(String tokenId) async {
try {
// Show loading
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
// Create charge
final charge = await _paymentService.createCharge(
token: tokenId,
amount: widget.amount,
currency: widget.currency,
);
// Hide loading
Navigator.of(context).pop();
// Handle 3DS if required
if (charge.requiresAuthorization) {
final authorized = await _paymentService.handle3DSecure(
context,
charge.authorizeUri!,
);
if (!authorized) {
_showError('Payment authentication cancelled');
return;
}
// Verify charge after 3DS
final verifiedCharge = await _paymentService.verifyCharge(charge.id);
if (verifiedCharge.isSuccessful) {
_showSuccess();
} else {
_showError('Payment verification failed');
}
} else if (charge.isSuccessful) {
_showSuccess();
} else {
_showError('Payment failed');
}
} catch (e) {
Navigator.of(context).pop(); // Hide loading
_showError(e.toString());
}
}
void _showSuccess() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Payment Successful'),
content: const Text('Your payment has been processed successfully.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop(); // Return to previous screen
},
child: const Text('OK'),
),
],
),
);
}
void _showError(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Payment Error'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Checkout'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Order Summary',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Amount:'),
Text(
'${widget.currency} ${(widget.amount / 100).toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
const SizedBox(height: 24),
const Text(
'Payment Details',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
PaymentForm(
amount: widget.amount,
currency: widget.currency,
onSubmit: _handlePayment,
),
],
),
),
);
}
}
Saved Cards Implementationโ
// lib/widgets/saved_cards_list.dart
import 'package:flutter/material.dart';
class SavedCard {
final String id;
final String brand;
final String lastDigits;
final int expirationMonth;
final int expirationYear;
SavedCard({
required this.id,
required this.brand,
required this.lastDigits,
required this.expirationMonth,
required this.expirationYear,
});
factory SavedCard.fromJson(Map<String, dynamic> json) {
return SavedCard(
id: json['id'] as String,
brand: json['brand'] as String,
lastDigits: json['last_digits'] as String,
expirationMonth: json['expiration_month'] as int,
expirationYear: json['expiration_year'] as int,
);
}
}
class SavedCardsList extends StatefulWidget {
final String customerId;
final Function(SavedCard) onCardSelected;
const SavedCardsList({
Key? key,
required this.customerId,
required this.onCardSelected,
}) : super(key: key);
@override
State<SavedCardsList> createState() => _SavedCardsListState();
}
class _SavedCardsListState extends State<SavedCardsList> {
List<SavedCard>? _cards;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadCards();
}
Future<void> _loadCards() async {
try {
final response = await http.get(
Uri.parse('https://api.yourapp.com/customers/${widget.customerId}/cards'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
setState(() {
_cards = (data['data'] as List)
.map((json) => SavedCard.fromJson(json))
.toList();
_isLoading = false;
});
} else {
throw Exception('Failed to load cards');
}
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
String _getCardIcon(String brand) {
switch (brand.toLowerCase()) {
case 'visa':
return 'assets/images/visa.png';
case 'mastercard':
return 'assets/images/mastercard.png';
case 'amex':
return 'assets/images/amex.png';
default:
return 'assets/images/card-default.png';
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Text('Error: $_error'),
);
}
if (_cards == null || _cards!.isEmpty) {
return const Center(
child: Text('No saved cards'),
);
}
return ListView.separated(
itemCount: _cards!.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final card = _cards![index];
return ListTile(
leading: Image.asset(
_getCardIcon(card.brand),
width: 40,
height: 25,
),
title: Text('${card.brand} โขโขโขโข ${card.lastDigits}'),
subtitle: Text(
'Expires ${card.expirationMonth.toString().padLeft(2, '0')}/${card.expirationYear % 100}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => widget.onCardSelected(card),
);
},
);
}
}
Error Handlingโ
// lib/utils/error_handler.dart
class PaymentErrorHandler {
static String getErrorMessage(OmiseException error) {
final errorMessages = {
'invalid_card': 'Invalid card information',
'insufficient_funds': 'Insufficient funds on card',
'stolen_or_lost_card': 'Card reported as lost or stolen',
'failed_processing': 'Payment processing failed',
'expired_card': 'Card has expired',
};
return errorMessages[error.code] ?? error.message;
}
static void showError(BuildContext context, OmiseException error) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Payment Error'),
content: Text(getErrorMessage(error)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
}
Best Practicesโ
State Managementโ
// Using Provider for state management
import 'package:provider/provider.dart';
class PaymentState extends ChangeNotifier {
bool _isProcessing = false;
String? _error;
Charge? _currentCharge;
bool get isProcessing => _isProcessing;
String? get error => _error;
Charge? get currentCharge => _currentCharge;
Future<void> processPayment({
required String token,
required int amount,
required String currency,
}) async {
_isProcessing = true;
_error = null;
notifyListeners();
try {
final service = PaymentService(baseUrl: 'https://api.yourapp.com');
_currentCharge = await service.createCharge(
token: token,
amount: amount,
currency: currency,
);
} catch (e) {
_error = e.toString();
} finally {
_isProcessing = false;
notifyListeners();
}
}
}
Validation Utilitiesโ
// lib/utils/validators.dart
class CardValidators {
static bool isValidCardNumber(String number) {
final cleaned = number.replaceAll(' ', '');
if (cleaned.length < 13 || cleaned.length > 19) {
return false;
}
return _luhnCheck(cleaned);
}
static bool _luhnCheck(String cardNumber) {
int sum = 0;
bool isEven = false;
for (int i = cardNumber.length - 1; i >= 0; i--) {
int digit = int.parse(cardNumber[i]);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 == 0;
}
static bool isValidCVV(String cvv) {
return RegExp(r'^\d{3,4}$').hasMatch(cvv);
}
static bool isValidExpiry(int month, int year) {
if (month < 1 || month > 12) {
return false;
}
final now = DateTime.now();
final expiryDate = DateTime(year, month + 1, 0);
return expiryDate.isAfter(now);
}
}
Testingโ
// test/payment_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Payment Validation', () {
test('Valid card number passes Luhn check', () {
expect(
CardValidators.isValidCardNumber('4242424242424242'),
true,
);
});
test('Invalid card number fails Luhn check', () {
expect(
CardValidators.isValidCardNumber('1234567890123456'),
false,
);
});
test('Valid CVV is accepted', () {
expect(CardValidators.isValidCVV('123'), true);
expect(CardValidators.isValidCVV('1234'), true);
});
test('Invalid CVV is rejected', () {
expect(CardValidators.isValidCVV('12'), false);
expect(CardValidators.isValidCVV('12345'), false);
});
test('Future expiry date is valid', () {
final nextYear = DateTime.now().year + 1;
expect(CardValidators.isValidExpiry(12, nextYear), true);
});
test('Past expiry date is invalid', () {
expect(CardValidators.isValidExpiry(1, 2020), false);
});
});
}
Troubleshootingโ
Common Issuesโ
Platform Channel Not Found
// Ensure plugin is registered in both platforms
// iOS: AppDelegate.swift
FlutterOmisePlugin.register(with: registry.registrar(forPlugin: "OmisePlugin")!)
// Android: MainActivity.kt
flutterEngine.plugins.add(OmisePlugin())
WebView Not Loading
# Add webview_flutter to pubspec.yaml
dependencies:
webview_flutter: ^4.4.0
# iOS: Update Info.plist for network access
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Deep Linking Not Working
// Verify URL scheme configuration
// Test with: adb shell am start -a android.intent.action.VIEW -d "myapp://payment/complete"
FAQโ
General Questionsโ
Q: Does Flutter have an official Omise SDK?
A: No official Flutter SDK exists. Use platform channels to integrate native iOS and Android SDKs or call the REST API directly.
Q: Can I use this with Flutter Web?
A: Platform channels don't work with Flutter Web. Use the REST API directly with HTTP requests for web support.
Q: What's the minimum Flutter version?
A: Flutter 3.0+ is recommended for stable null safety and modern features.
Q: How do I test payments?
A: Use test card 4242 4242 4242 4242 with any future expiry and any 3-digit CVV. Use test public key.
Q: Can I use this in production?
A: Yes, but ensure you properly test platform channel implementations and handle all edge cases.
Q: How do I handle iOS/Android differences?
A: Use Platform.isIOS and Platform.isAndroid to conditionally handle platform-specific logic.
Implementation Questionsโ
Q: Should I use platform channels or REST API?
A: Platform channels provide better security and native SDK features. REST API is simpler but requires more security considerations.
Q: How do I handle state management?
A: Use Provider, Riverpod, Bloc, or GetX based on your app's architecture.
Q: Can I use this with GetX/Bloc?
A: Yes, the payment logic can be integrated with any state management solution.
Q: How do I cache saved cards?
A: Use shared_preferences or hive for simple caching, or implement proper state management with automatic refresh.
Related Resourcesโ
- Mobile Payments Overview
- iOS SDK Payments
- Android SDK Payments
- React Native Integration
- Charges API Reference
- Tokens API Reference
- Testing Guide