import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_cashful/api.dart';
import 'package:webview_flutter/webview_flutter.dart';
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
class CashfulCheckoutConfig {
// IMPORTANT: In production, move session creation to your backend.
// The API key should never be shipped in a mobile app binary.
static const String baseUrl = 'https://sandbox.api.cashful.africa';
static const String apiKey = 'YOUR_API_KEY';
static const String merchantId = 'YOUR_MERCHANT_ID';
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
void main() => runApp(const MaterialApp(home: CheckoutDemoPage()));
// ---------------------------------------------------------------------------
// Checkout event model
// ---------------------------------------------------------------------------
class CheckoutEvent {
final DateTime timestamp;
final String type;
final String message;
final Map<String, dynamic>? data;
CheckoutEvent({required this.type, required this.message, this.data})
: timestamp = DateTime.now();
bool get isTerminal =>
message == 'payment.completed' || message == 'payment.failed';
bool get isSuccess => message == 'payment.completed';
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
class CheckoutDemoPage extends StatefulWidget {
const CheckoutDemoPage({super.key});
@override
State<CheckoutDemoPage> createState() => _CheckoutDemoPageState();
}
class _CheckoutDemoPageState extends State<CheckoutDemoPage> {
// SDK
late final ApiClient _apiClient;
late final CheckoutsApi _checkoutsApi;
// WebView
late final WebViewController _webViewController;
// State
String? _sessionId;
bool _isLoading = false;
bool _isCheckoutVisible = false;
bool _isFinished = false;
bool _hasHandledTerminalEvent = false;
bool? _paymentSuccess;
Map<String, dynamic>? _resultData;
final List<CheckoutEvent> _events = [];
String? _errorMessage;
// -------------------------------------------------------------------------
// JavaScript bridge scripts
// -------------------------------------------------------------------------
static const _messageBridgeScript = '''
(function() {
var checkoutOrigin = window.location.origin;
function clearStorage(win) {
try {
if (win.location.origin !== checkoutOrigin) return;
win.localStorage.clear();
win.sessionStorage.clear();
} catch(e) {}
}
function clearCheckoutStorage() {
clearStorage(window);
try {
for (var i = 0; i < window.frames.length; i++) {
try { clearStorage(window.frames[i]); } catch(e) {}
}
} catch(e) {}
}
function isTerminalEvent(payload) {
if (typeof payload === 'string') {
try { payload = JSON.parse(payload); } catch(e) { return false; }
}
if (!payload || typeof payload !== 'object') return false;
var evt = payload.event || payload.command || '';
return evt === 'payment.completed' || evt === 'payment.failed';
}
function setupBridge(win) {
var forward = function(payload) {
if (isTerminalEvent(payload)) {
clearCheckoutStorage();
}
var msg = typeof payload === 'object'
? JSON.stringify(payload) : payload;
try { CheckoutEvents.postMessage(msg); } catch(e) {}
};
if (!win.cashful) {
win.cashful = { postMessage: forward };
win.ReactNativeWebView = { postMessage: forward };
win.webkit = win.webkit || {};
win.webkit.messageHandlers = win.webkit.messageHandlers || {};
win.webkit.messageHandlers.cashful = { postMessage: forward };
win.Android = { postMessage: forward };
}
var originalPostMessage = win.postMessage;
win.postMessage = function(msg, origin) {
forward(msg);
originalPostMessage.call(win, msg, origin);
};
win.addEventListener('message', function(e) { forward(e.data); });
}
setupBridge(window);
try {
for (var i = 0; i < window.frames.length; i++) {
try { setupBridge(window.frames[i]); } catch(e) {}
}
} catch(e) {}
if (window.MutationObserver) {
new MutationObserver(function() {
try {
for (var i = 0; i < window.frames.length; i++) {
try { setupBridge(window.frames[i]); } catch(e) {}
}
} catch(e) {}
}).observe(document.body || document.documentElement,
{ childList: true, subtree: true });
}
})();
''';
static const _disableBridgeScript = '''
try {
function noop(){}
window.cashful = { postMessage: noop };
window.ReactNativeWebView = { postMessage: noop };
if (window.webkit && window.webkit.messageHandlers) {
window.webkit.messageHandlers.cashful = { postMessage: noop };
}
window.Android = { postMessage: noop };
} catch(e) {}
''';
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@override
void initState() {
super.initState();
_initSdk();
_initWebView();
}
void _initSdk() {
final auth = ApiKeyAuth('header', 'x-api-key');
auth.apiKey = CashfulCheckoutConfig.apiKey;
_apiClient = ApiClient(
basePath: CashfulCheckoutConfig.baseUrl,
authentication: auth,
);
_checkoutsApi = CheckoutsApi(_apiClient);
}
void _initWebView() {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: _handleNavigationRequest,
onPageStarted: (url) {
_webViewController.runJavaScript(_messageBridgeScript);
},
onPageFinished: (url) {
_webViewController.runJavaScript(_messageBridgeScript);
},
onWebResourceError: (error) {
debugPrint('[WebView] Error: ${error.description}');
},
),
)
..setOnConsoleMessage((msg) {
debugPrint('[WebView Console] ${msg.message}');
})
..addJavaScriptChannel(
'CheckoutEvents',
onMessageReceived: _handleCheckoutMessage,
);
}
// -------------------------------------------------------------------------
// Checkout session creation
// -------------------------------------------------------------------------
Future<void> _startCheckout() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_events.clear();
_hasHandledTerminalEvent = false;
_isFinished = false;
_paymentSuccess = null;
_resultData = null;
});
try {
final dto = CreateCheckoutSessionDto(
merchantId: CashfulCheckoutConfig.merchantId,
currency: 'ZAR',
totalAmount: 5500, // R55.00
lineItems: [
LineItemDto(
name: 'Premium Widget',
amount: 2500,
currency: 'ZAR',
quantity: 2,
),
LineItemDto(
name: 'Shipping',
amount: 500,
currency: 'ZAR',
quantity: 1,
),
],
metadata: {
'orderId': 'order_${DateTime.now().millisecondsSinceEpoch}',
},
hostedCheckoutConfig: HostedCheckoutConfigDto(
merchantAlias: 'Demo Store',
requireContact: true,
methods: [HostedCheckoutConfigDtoMethodsEnum.card],
),
);
final session = await _checkoutsApi.createCheckoutSession(dto);
if (session == null) {
setState(() {
_isLoading = false;
_errorMessage = 'Failed to create checkout session: null response';
});
return;
}
_sessionId = session.id;
_webViewController.loadRequest(Uri.parse(session.sessionUrl));
setState(() {
_isLoading = false;
_isCheckoutVisible = true;
});
} on ApiException catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'API error ${e.code}: ${e.message}';
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Unexpected error: $e';
});
}
}
// -------------------------------------------------------------------------
// Navigation handling
// -------------------------------------------------------------------------
NavigationDecision _handleNavigationRequest(NavigationRequest request) {
if (request.url.contains('cashful.africa/checkout/callback')) {
debugPrint('[Checkout] Callback redirect intercepted: ${request.url}');
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
// -------------------------------------------------------------------------
// Event handling
// -------------------------------------------------------------------------
void _handleCheckoutMessage(JavaScriptMessage message) {
try {
final decoded = jsonDecode(message.message);
final eventType = decoded['event']?.toString() ??
decoded['command']?.toString() ??
'unknown';
final eventData =
decoded is Map ? Map<String, dynamic>.from(decoded) : null;
final event = CheckoutEvent(
type: 'received',
message: eventType,
data: eventData,
);
setState(() => _events.insert(0, event));
if (event.isTerminal) {
_handleTerminalEvent(event);
}
} catch (_) {
debugPrint('[Checkout] Unparseable: ${message.message}');
}
}
// -------------------------------------------------------------------------
// Terminal event handling and cleanup
// -------------------------------------------------------------------------
Future<void> _handleTerminalEvent(CheckoutEvent event) async {
if (_hasHandledTerminalEvent) return;
_hasHandledTerminalEvent = true;
// 1. Disable the bridge
_webViewController.runJavaScript(_disableBridgeScript);
// 2. Clear cookies
await WebViewCookieManager().clearCookies();
// 3. Brief pause for pending operations
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
// 4. Navigate away
_webViewController.loadRequest(Uri.parse('about:blank'));
await Future.delayed(const Duration(milliseconds: 100));
// 5. Server-side verification
bool verified = false;
if (event.isSuccess && _sessionId != null) {
verified = await _verifyPayment(_sessionId!);
}
if (!mounted) return;
// 6. Show result
final payloadData = event.data?['data'];
setState(() {
_isCheckoutVisible = false;
_isFinished = true;
_paymentSuccess = event.isSuccess && (event.isSuccess ? verified : true);
_resultData = payloadData is Map
? Map<String, dynamic>.from(payloadData)
: null;
});
}
Future<bool> _verifyPayment(String sessionId) async {
try {
final session = await _checkoutsApi.retrieveCheckoutSession(sessionId);
return session?.status == 'complete';
} catch (e) {
debugPrint('[Checkout] Verification error: $e');
return false;
}
}
// -------------------------------------------------------------------------
// Reset for new checkout
// -------------------------------------------------------------------------
void _resetCheckout() {
setState(() {
_sessionId = null;
_isCheckoutVisible = false;
_isFinished = false;
_hasHandledTerminalEvent = false;
_paymentSuccess = null;
_resultData = null;
_events.clear();
_errorMessage = null;
});
}
// -------------------------------------------------------------------------
// UI
// -------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Cashful Checkout'),
actions: [
if (_isFinished)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _resetCheckout,
tooltip: 'New Checkout',
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isCheckoutVisible) {
return WebViewWidget(controller: _webViewController);
}
if (_isFinished) {
return _buildResultView();
}
return _buildStartView();
}
// -- Start view --
Widget _buildStartView() {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shopping_cart_outlined, size: 64,
color: Colors.grey),
const SizedBox(height: 16),
const Text(
'Cashful Checkout Demo',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Creates a checkout session via the SDK and opens it in a WebView.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 24),
const Text('Order Summary',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
_buildOrderRow('Premium Widget x2', 'R50.00'),
_buildOrderRow('Shipping x1', 'R5.00'),
const Divider(),
_buildOrderRow('Total', 'R55.00',
bold: true),
const SizedBox(height: 24),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
borderRadius: BorderRadius.circular(8),
),
child: Text(_errorMessage!,
style: TextStyle(color: Colors.red[700])),
),
const SizedBox(height: 16),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isLoading ? null : _startCheckout,
icon: _isLoading
? const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2,
color: Colors.white))
: const Icon(Icons.payment),
label: Text(_isLoading ? 'Creating session...' : 'Pay R55.00'),
),
),
],
),
),
);
}
Widget _buildOrderRow(String label, String amount, {bool bold = false}) {
final style = bold
? const TextStyle(fontWeight: FontWeight.bold)
: const TextStyle();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label, style: style), Text(amount, style: style)],
),
);
}
// -- Result view --
Widget _buildResultView() {
final success = _paymentSuccess ?? false;
return ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 32),
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
size: 80,
color: success ? Colors.green : Colors.red,
),
const SizedBox(height: 16),
Text(
success ? 'Payment Successful' : 'Payment Failed',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
if (!success && _resultData?['reason'] != null) ...[
const SizedBox(height: 8),
Text(
_resultData!['reason'].toString(),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
],
const SizedBox(height: 24),
// Transaction details
if (_resultData != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Transaction Details',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_resultData!['transactionId'] != null)
_buildDetailRow(
'Transaction ID', _resultData!['transactionId']),
if (_resultData!['amount'] != null)
_buildDetailRow('Amount',
'R${(_resultData!['amount'] / 100).toStringAsFixed(2)}'),
if (_resultData!['paymentMethod'] != null)
_buildDetailRow(
'Method', _resultData!['paymentMethod']),
if (_resultData!['reason'] != null)
_buildDetailRow(
'Reason', _resultData!['reason']),
],
),
),
),
// Order items (present on payment.completed)
if (success && _resultData?['orderItems'] != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Order Items',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
...(_resultData!['orderItems'] as List).map((item) {
final name = item['name'] ?? '';
final qty = item['quantity'] ?? 1;
final price = item['price'] ?? 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${qty}x $name'),
Text('R${(price * qty / 100).toStringAsFixed(2)}'),
],
),
);
}),
],
),
),
),
// Contact info (present on payment.completed)
if (success && _resultData?['contactInfo'] != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Contact Info',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if ((_resultData!['contactInfo'] as Map)['email'] != null)
_buildDetailRow('Email',
_resultData!['contactInfo']['email']),
if ((_resultData!['contactInfo'] as Map)['phone'] != null)
_buildDetailRow('Phone',
_resultData!['contactInfo']['phone']),
],
),
),
),
const SizedBox(height: 16),
// Event history
if (_events.isNotEmpty)
ExpansionTile(
title: Text('Event History (${_events.length})'),
children: _events.map((e) {
return ListTile(
dense: true,
leading: Icon(
e.isTerminal ? Icons.flag : Icons.arrow_forward,
size: 16,
color: e.isTerminal
? (e.isSuccess ? Colors.green : Colors.red)
: Colors.blue,
),
title: Text(e.message,
style: const TextStyle(fontSize: 13,
fontFamily: 'monospace')),
subtitle: Text(
'${e.timestamp.hour.toString().padLeft(2, '0')}:'
'${e.timestamp.minute.toString().padLeft(2, '0')}:'
'${e.timestamp.second.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 11),
),
);
}).toList(),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: _resetCheckout,
icon: const Icon(Icons.refresh),
label: const Text('New Checkout'),
),
],
);
}
Widget _buildDetailRow(String label, dynamic value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(label,
style: TextStyle(
color: Colors.grey[600], fontWeight: FontWeight.w500)),
),
Expanded(
child: Text(value.toString(),
style: const TextStyle(fontFamily: 'monospace')),
),
],
),
);
}
}