swichpay_flutter 2.1.2
swichpay_flutter: ^2.1.2 copied to clipboard
Flutter SDK for integrating Swich payment services.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:swichpay_flutter/swichpay.dart';
import 'package:switchpay_flutter_example/gradiant_button.dart';
// Static QR, UnionPay, and Tokenization commented out for now - not
// something this storefront wants to offer yet.
const _checkoutCategories = [
PaymentCategory.ewallet,
PaymentCategory.biller,
PaymentCategory.bankAccount,
PaymentCategory.visaMasterCardPayment,
// PaymentCategory.unionPayCardPayment,
PaymentCategory.dqrcPayment,
// PaymentCategory.sqrcPayment,
PaymentCategory.rtpNowPayment,
// PaymentCategory.tokenization,
PaymentCategory.rtpEwalletPayment,
];
// Mutable (not const) so the "Sandbox Credentials" fields below can change them at runtime for
// testing - editing either calls [_initializeSwichPay] again, so a change takes effect on the
// very next checkout attempt without restarting the app.
String _sandboxClientId = '4f744abad406438aa40c6e4afaf8b015';
// The OAuth client_credentials secret - matches native's PaymentConfig.secretKey. Required for
// the payment sheet / session-based checkout; the SDK exchanges this for a bearer token itself.
// NOT the same value as _sandboxSecretHashKey below - mixing them up gets an "invalid_client"
// 401 from the token endpoint.
String _sandboxClientSecret =
'52dbc39a18b146f5a72687e490c8a0687973412fbdcf43c28a8ced01cf7e72e9';
// Only required for SwichPay.presentCardCheckout (the direct/session-less card flow) - matches
// this same sandbox merchant's native PaymentConfig.secretHashKey. A different secret than
// _sandboxClientSecret above.
const _sandboxSecretHashKey = '79A9528E04383A1A';
// clientId/referrer must match what's registered with SwichPay for this merchant; the checkout
// API validates both on every request. That's the whole integration - no backend proxy to stand
// up; the SDK exchanges clientId/clientSecret for an OAuth token itself and manages sessions
// internally.
void _initializeSwichPay() {
SwichPay.initialize(
clientId: _sandboxClientId,
clientSecret: _sandboxClientSecret,
referrer: 'http://127.0.0.1:5500/',
merchantId: 1178,
appId: 'com.yourapp',
environment: Environment.sandbox,
secretHashKey: _sandboxSecretHashKey,
);
}
void main() {
_initializeSwichPay();
runApp(const SwichPayExampleApp());
}
class SwichPayExampleApp extends StatelessWidget {
const SwichPayExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Swich Pay',
theme: ThemeData(
colorSchemeSeed: const Color(0xFF2563EB),
scaffoldBackgroundColor: Colors.white,
useMaterial3: true,
),
home: const CheckoutPage(),
);
}
}
/// Identifies which checkout button is currently in flight - see
/// `_CheckoutPageState._activeCheckout`.
enum _CheckoutAction {
checkout,
wallet,
bankTransfer,
oneBill,
qr,
rtpNow,
rtpEwallet,
card,
}
/// A dummy storefront product - just enough (name/price/icon) to drive the
/// add-to-cart demo below.
class _Product {
const _Product({required this.name, required this.price, required this.icon});
final String name;
final int price;
final IconData icon;
}
class CheckoutPage extends StatefulWidget {
const CheckoutPage({super.key});
@override
State<CheckoutPage> createState() => _CheckoutPageState();
}
class _CheckoutPageState extends State<CheckoutPage> {
String _status = 'No payment attempted yet.';
/// Which checkout button is currently in flight, if any - `null` means
/// none. Was a single shared `bool` before, which made every button's
/// `GradientPayButton(loading: ...)` light up together since they all
/// read the same flag; keyed per-button now so only the one actually
/// tapped shows its loader.
_CheckoutAction? _activeCheckout;
// Dummy catalog - prices kept small (1-10 PKR each) so a cart full of
// everything still makes for a quick sandbox test. A small enough cart
// total can get rejected server-side with HTTP 400, code "0012"
// ("invalid minimum amount") - a real backend validation, not an SDK
// bug, confirmed live - add more items to the cart to clear it.
static const _products = [
_Product(name: 'Wireless Earbuds', price: 10, icon: Icons.headphones),
_Product(name: 'Phone Case', price: 5, icon: Icons.phone_iphone),
_Product(name: 'USB Cable', price: 3, icon: Icons.cable),
_Product(name: 'Screen Protector', price: 4, icon: Icons.shield_outlined),
];
/// Cart quantity per product name - absent/`0` means not in the cart.
final Map<String, int> _cartQuantities = {};
// Customer details that used to be hardcoded into _cartDetails() -
// pre-filled with those same values so checkout still works untouched,
// but now editable via the "Your Details" form below.
final _nameController = TextEditingController(text: 'anas arif');
final _emailController = TextEditingController(text: 'anas@gmail.com');
final _phoneController = TextEditingController(text: '03032290204');
final _cnicController = TextEditingController(text: '4220153560816');
final _customerTokenController = TextEditingController(
text: 'E02C50B2-6697-4845-AF9A-3F34DE8C59E5',
);
// Pre-filled with the current default client id/secret - editing either updates the matching
// top-level _sandboxClientId/_sandboxClientSecret and re-runs SwichPay.initialize(), so a
// change takes effect on the next checkout without restarting the app.
final _clientIdController = TextEditingController(text: _sandboxClientId);
final _clientSecretController = TextEditingController(
text: _sandboxClientSecret,
);
@override
void initState() {
super.initState();
SwichPay.onResult((result) {
setState(() => _status = 'Result: ${result.status} (${result.message})');
});
_clientIdController.addListener(() {
_sandboxClientId = _clientIdController.text.trim();
_initializeSwichPay();
});
_clientSecretController.addListener(() {
_sandboxClientSecret = _clientSecretController.text.trim();
_initializeSwichPay();
});
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_cnicController.dispose();
_customerTokenController.dispose();
_clientIdController.dispose();
_clientSecretController.dispose();
super.dispose();
}
int get _cartTotal => _products.fold(
0,
(sum, product) =>
sum + product.price * (_cartQuantities[product.name] ?? 0),
);
int get _cartItemCount =>
_cartQuantities.values.fold(0, (sum, qty) => sum + qty);
void _incrementQuantity(_Product product) {
setState(() {
_cartQuantities[product.name] = (_cartQuantities[product.name] ?? 0) + 1;
});
}
void _decrementQuantity(_Product product) {
setState(() {
final current = _cartQuantities[product.name] ?? 0;
if (current <= 1) {
_cartQuantities.remove(product.name);
} else {
_cartQuantities[product.name] = current - 1;
}
});
}
/// Cart contents rendered as a single `item` string (e.g. `"Wireless
/// Earbuds x2, Phone Case x1"`) since [PaymentDetails.item] only takes one
/// line of text - a real storefront would send its own order/line-item
/// breakdown instead.
String get _cartSummary => _products
.where((p) => (_cartQuantities[p.name] ?? 0) > 0)
.map((p) => '${p.name} x${_cartQuantities[p.name]}')
.join(', ');
PaymentDetails _cartDetails() {
// A fresh timestamp per checkout attempt - reusing the same
// billReferenceNo/email across repeated test runs had the gateway
// treating them as the same order/customer instead of a new one.
final unique = DateTime.now().millisecondsSinceEpoch;
final emailBase = _emailController.text.trim();
final atIndex = emailBase.indexOf('@');
final uniqueEmail = atIndex == -1
? '$emailBase+$unique'
: '${emailBase.substring(0, atIndex)}+$unique${emailBase.substring(atIndex)}';
return PaymentDetails(
orderNumber: 'ORD$unique',
customerTransactionId: 'new-$unique',
billReferenceNo: 'test-$unique',
merchantName: 'anas1 Merchant',
consumerName: _nameController.text.trim(),
amount: _cartTotal,
currency: 'PKR',
email: uniqueEmail,
msisdn: _phoneController.text.trim(),
cnic: _cnicController.text.trim(),
item: _cartSummary,
successRedirectUrl: 'https://example.com/success',
failedRedirectUrl: 'https://example.com/failure',
attempts: 5,
expiryMinutes: 15,
merchantCustomerToken: _customerTokenController.text.trim().isEmpty
? null
: _customerTokenController.text.trim(),
);
}
/// Shows [result] both as the persistent `_status` line (which can be
/// scrolled out of view) and as an immediate, color-coded SnackBar so
/// it's visible no matter where the user's scrolled to when a checkout
/// finishes. Prefers [PaymentResult.message] (the actual API/vendor
/// text - e.g. a real decline reason or exception detail) over the
/// bare `status` string, since "Result: failed (null)" or a raw status
/// word on its own isn't actually informative.
void _showResult(PaymentResult? result) {
final message = switch (result) {
null => 'Sheet dismissed.',
PaymentResult(:final message?) when message.trim().isNotEmpty => message,
PaymentResult(:final status) => status,
};
setState(() => _status = message);
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: result == null
? null
: (result.isSuccess
? Colors.green.shade700
: Colors.red.shade700),
duration: const Duration(seconds: 5),
),
);
}
/// Every checkout button (session-based or direct) needs at least one
/// product in the cart - shows an error instead of silently no-op-ing
/// when the user taps one with an empty cart.
bool _requireProductSelected() {
if (_cartItemCount > 0) return true;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('Select a product first.')));
return false;
}
Future<void> _checkout() async {
if (!_requireProductSelected() || _activeCheckout != null) return;
setState(() => _activeCheckout = _CheckoutAction.checkout);
try {
final result = await SwichPay.presentPaymentSheet(
context: context,
details: _cartDetails(),
categories: _checkoutCategories,
);
if (!mounted) return;
_showResult(result);
} finally {
if (mounted) setState(() => _activeCheckout = null);
}
}
/// Direct (session-less) checkouts - each skips the "Choose Payment
/// Method" list/payment session entirely and posts straight to its own
/// single-category endpoint. [presenter] is one of
/// `SwichPay.present*Checkout`. [action] identifies which button
/// triggered this, so only that one shows its loader.
Future<void> _directCheckout(
_CheckoutAction action,
Future<PaymentResult?> Function({
required BuildContext context,
required PaymentDetails details,
})
presenter,
) async {
if (!_requireProductSelected() || _activeCheckout != null) return;
setState(() => _activeCheckout = action);
try {
final result = await presenter(context: context, details: _cartDetails());
if (!mounted) return;
_showResult(result);
} finally {
if (mounted) setState(() => _activeCheckout = null);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Swich Pay')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_status, textAlign: TextAlign.center),
const SizedBox(height: 24),
const Text(
'Sandbox Credentials (testing)',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
TextField(
controller: _clientIdController,
decoration: const InputDecoration(
labelText: 'Client ID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _clientSecretController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Client Secret',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
const Text(
'Your Details',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Mobile Number',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _cnicController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'CNIC',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _customerTokenController,
decoration: const InputDecoration(
labelText: 'Merchant Customer Token (optional)',
helperText:
'A stable id for this customer in your own system - '
'set this to get their saved cards/wallets back in '
'the session.',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
const Text(
'Products',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
for (final product in _products) ...[
_ProductTile(
product: product,
quantity: _cartQuantities[product.name] ?? 0,
onAdd: () => _incrementQuantity(product),
onIncrement: () => _incrementQuantity(product),
onDecrement: () => _decrementQuantity(product),
),
const SizedBox(height: 12),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Cart ($_cartItemCount item${_cartItemCount == 1 ? '' : 's'})',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
'PKR $_cartTotal',
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
],
),
const SizedBox(height: 16),
GradientPayButton(
label: _cartItemCount == 0
? 'Add items to checkout'
: 'Checkout - PKR $_cartTotal',
loading: _activeCheckout == _CheckoutAction.checkout,
onPressed: _checkout,
),
const SizedBox(height: 24),
const Text(
'Direct checkouts (no payment session)',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'Pay with Wallet',
loading: _activeCheckout == _CheckoutAction.wallet,
onPressed: () => _directCheckout(
_CheckoutAction.wallet,
SwichPay.presentEwalletCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'Pay with Bank Transfer',
loading: _activeCheckout == _CheckoutAction.bankTransfer,
onPressed: () => _directCheckout(
_CheckoutAction.bankTransfer,
SwichPay.presentBankTransferCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'Pay via 1Bill',
loading: _activeCheckout == _CheckoutAction.oneBill,
onPressed: () => _directCheckout(
_CheckoutAction.oneBill,
SwichPay.presentOneBillCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'Pay with QR',
loading: _activeCheckout == _CheckoutAction.qr,
onPressed: () => _directCheckout(
_CheckoutAction.qr,
SwichPay.presentQrCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'RTP Now',
loading: _activeCheckout == _CheckoutAction.rtpNow,
onPressed: () => _directCheckout(
_CheckoutAction.rtpNow,
SwichPay.presentRtpNowCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'RTP E-Wallet',
loading: _activeCheckout == _CheckoutAction.rtpEwallet,
onPressed: () => _directCheckout(
_CheckoutAction.rtpEwallet,
SwichPay.presentRtpEwalletCheckout,
),
),
const SizedBox(height: 12),
GradientPayButton(
label: 'Pay with Card',
loading: _activeCheckout == _CheckoutAction.card,
onPressed: () => _directCheckout(
_CheckoutAction.card,
SwichPay.presentCardCheckout,
),
),
],
),
),
),
);
}
}
class _ProductTile extends StatelessWidget {
const _ProductTile({
required this.product,
required this.quantity,
required this.onAdd,
required this.onIncrement,
required this.onDecrement,
});
final _Product product;
final int quantity;
final VoidCallback onAdd;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Icon(product.icon, color: const Color(0xFF2563EB)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'PKR ${product.price}',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
if (quantity == 0)
OutlinedButton(onPressed: onAdd, child: const Text('Add to Cart'))
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onDecrement,
icon: const Icon(Icons.remove_circle_outline),
color: const Color(0xFF2563EB),
),
Text(
'$quantity',
style: const TextStyle(fontWeight: FontWeight.w700),
),
IconButton(
onPressed: onIncrement,
icon: const Icon(Icons.add_circle_outline),
color: const Color(0xFF2563EB),
),
],
),
],
),
),
);
}
}