Skip to main content

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:

  1. Flutter SDK 3.0 or higher installed
  2. Native iOS and Android development environments
  3. Backend API for charge creation
  4. 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.

Next Stepsโ€‹