We create a Flutter application for payment via SBP without native
![](https://habrastorage.org/getpro/habr/upload_files/bc6/00b/b1c/bc600bb1c138fdd21736e0891508c8b1.jpg)
Hi all! My name is Murat NasirovI'm a Flutter developer in Friflex. We develop mobile applications for businesses and specialize in Flutter.
Previously, I shared my experience on how to integrate SBP using native NSPK solutions (National Payment Card System). In this article, I'll show you how to do this using a Flutter app and two packages from pub.dev.
Let's remember the structure
The SBP API website describes how to use their format for payment by unique link. A regular payment link sends the user to the SBP website, where a QR code is generated from it. Regular link format:
https://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G? type=01&bank=100000000007&crc=0C8A
The disadvantage of a QR code is that it must be scanned in the bank's application. After this, the payment window and information about the payment acceptor opens.
![](https://habrastorage.org/getpro/habr/upload_files/1aa/025/5cd/1aa0255cd5abe3e2c9e15d3d701a43e4.jpg)
Each bank that is connected to the SBP has its own scheme. This is a unique id. It is required to go to the banking application. A complete list of banks that use SBP can be found Here.
If we take the field schema
from the link above and paste it into the payment link instead https
, then we will get a deep link that will immediately open the application of the desired bank. For example, a link for Otkritie Bank will look like this:
bank100000000015://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G? type=01&bank=100000000007&crc=0C8A
Banks whose apps have been removed from the App Store and Google Play may create clones. The clones have different package_name
and the field schema
remains the same as the original. Therefore, if the user has an analogue instead of the original SberBank application, for example, Smart Online, the payment via SBP will be the same.
Writing an application
Let's move on to creating the application. We do this in several steps:
1. We get list banks.
2. We create a list of banks. To do this, three fields are enough: bankName
, logoURL
And schema
.
3. We insert these fields into the object. We create a list that displays all the banks on the screen.
4. Set up a click listener (GestureDetector
) for each bank. Change in payment links https
on schema
the corresponding bank.
5. Listen to the application lifecycle using AppLifecycleListener
. Using a callback onRestart
, we put a flag in it. We set the second flag after switching to the bank application.
6. After returning from the bank application, we send a request to check the payment status.
We only need two packages in the project. IN pubspec.yaml
indicate:
dependencies:
http: any
url_launcher: any
We select the version depending on our Flutter SDK. Setting up receiving a list of banks. Create an object for the fields: bankName
, logoURL
And schema
.
class BankItem {
const BankItem({
required this.bankName,
required this.logoURL,
required this.schema,
});
final String bankName;
final String logoURL;
final String schema;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BankItem &&
runtimeType == other.runtimeType &&
bankName == other.bankName &&
logoURL == other.logoURL &&
schema == other.schema;
@override
int get hashCode => bankName.hashCode ^ logoURL.hashCode ^ schema.hashCode;
}
We request a list of banks participating in the SBP:
Future<List<BankItem>> _getBankList() async {
try {
final response = await http.get(
Uri.parse('https://qr.nspk.ru/proxyapp/c2bmembers.json'),
);
final decodedMap = jsonDecode(response.body) as Map<String, dynamic>;
final bankList = decodedMap['dictionary'] as List;
final mappedList = <BankItem>[];
for (final item in bankList) {
final bankName = item['bankName'] as String?;
final logoURL = item['logoURL'] as String?;
final schema = item['schema'] as String?;
if (schema == null || logoURL == null || bankName == null) continue;
if (schema.isEmpty || logoURL.isEmpty || bankName.isEmpty) continue;
mappedList.add(
BankItem(
bankName: utf8.decode(bankName.codeUnits),
logoURL: logoURL,
schema: schema,
),
);
}
return mappedList;
} on Object {
return <BankItem>[];
}
}
Here we use utf8
due to encoding issues. If a bank does not have a scheme, name or picture, we do not add it to the list. To display a list of banks, use FutureBuilder
And ListView
.
The lifecycle of our application changes when it is minimized or another application is opened from it. To track the state of the lifecycle, we use WidgetsBindingObserver
together with AppLifecycleListener.
Now let's create Map
with two flags, which will store two states: the state of minimizing and then opening the application (when onRestart
) and the state of transition to the bank application via deep link. Map
wrap in ValueNotifier
since it is necessary to keep track of the state when both flags true
.
class _SbpPayScreenState extends State<SbpPayScreen>
with WidgetsBindingObserver {
late final ValueNotifier<Map<String, bool>> _statesMapNotifier;
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_statesMapNotifier = ValueNotifier<Map<String, bool>>(
{'wasRestarted': false, 'wasTransited': false},
);
_lifecycleListener = AppLifecycleListener(
onRestart: () {
_statesMapNotifier.value = {
'wasRestarted': true,
'wasTransited': _statesMapNotifier.value['wasTransited'] ??
false,
};
},
);
_statesMapNotifier.addListener(() {
final wasRestarted = _statesMapNotifier.value['wasRestarted'] ??
false;
final wasTransited = _statesMapNotifier.value['wasTransited'] ??
false;
if (wasRestarted && wasTransited) {
Future.delayed(
const Duration(seconds: 4),
() {
_statesMapNotifier.value = {
'wasRestarted': false,
'wasTransited': false,
};
},
);
}
});
}
@override
void dispose() {
_lifecycleListener.dispose();
_statesMapNotifier.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
...
}
IN Map
we use two keys:
wasRestarted
— flag when the application was minimized and opened again;wasTransited
— flag when the transition to the bank application occurred.
If both flags become true
, which means the user has switched to the bank application. To find out if he paid, we request information from the backend. For this purpose _statesMapNotifier
there is a listener simulating a data request event.
You can come up with your own logic, I show one of the options for how this can be tracked. IN build
we add ValueListenableBuilder
. It puts the loading status.
ValueListenableBuilder(
valueListenable: _statesMapNotifier,
builder: (_, statesMap, __) {
final wasRestarted = statesMap['wasRestarted'] ?? false;
final wasTransited = statesMap['wasTransited'] ?? false;
if (wasRestarted && wasTransited) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Имитация запроса для проверки оплаты заказа...',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 21),
),
SizedBox(height: 16),
CircularProgressIndicator(),
],
),
),
);
}
...
}
)
Create a method for opening a bank. For each element from the list of banks you need to add a click listener. From the element we take schema
which in the link replaces https
.
Future<void> _openBank(BuildContext context, {required String schema}) async {
ScaffoldMessenger.of(context).removeCurrentSnackBar();
final paymentUrl = widget.paymentUrl.replaceAll(RegExp('https://'), '');
final link = '$schema://$paymentUrl';
try {
final wasLaunched = await launchUrlString(
link,
mode: LaunchMode.externalApplication,
);
if (!mounted) return;
_statesMapNotifier.value = {
'wasRestarted': false,
'wasTransited': wasLaunched,
};
} on Object {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Такого банка нет')),
);
}
}
When the deep link opened (wasLaunched
became true
), flag wasRestarted
becomes false
. wasRestarted
is updated every time the application is minimized and reopened. This is how we track the moment when a user switches to the bank application and back.
The application should work like this.
![](https://habrastorage.org/getpro/habr/upload_files/64f/e15/278/64fe152786af8a419fc776a2eb280c3c.gif)
Full application code
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const _paymentUrl="https://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G?type=01&bank=100000000007&crc=0C8A";
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'SBP Pay Demo',
debugShowCheckedModeBanner: false,
home: SbpPayScreen(paymentUrl: _paymentUrl),
);
}
}
class SbpPayScreen extends StatefulWidget {
const SbpPayScreen({
required this.paymentUrl,
super.key,
});
final String paymentUrl;
@override
State<SbpPayScreen> createState() => _SbpPayScreenState();
}
class _SbpPayScreenState extends State<SbpPayScreen>
with WidgetsBindingObserver {
late final ValueNotifier<Map<String, bool>> _statesMapNotifier;
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_statesMapNotifier = ValueNotifier<Map<String, bool>>(
{'wasRestarted': false, 'wasTransited': false},
);
_lifecycleListener = AppLifecycleListener(
onRestart: () {
_statesMapNotifier.value = {
'wasRestarted': true,
'wasTransited': _statesMapNotifier.value['wasTransited'] ?? false,
};
},
);
_statesMapNotifier.addListener(() {
final wasRestarted = _statesMapNotifier.value['wasRestarted'] ?? false;
final wasTransited = _statesMapNotifier.value['wasTransited'] ?? false;
if (wasRestarted && wasTransited) {
Future.delayed(
const Duration(seconds: 4),
() {
_statesMapNotifier.value = {
'wasRestarted': false,
'wasTransited': false,
};
},
);
}
});
}
@override
void dispose() {
_lifecycleListener.dispose();
_statesMapNotifier.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: ValueListenableBuilder(
valueListenable: _statesMapNotifier,
builder: (_, statesMap, __) {
final wasRestarted = statesMap['wasRestarted'] ?? false;
final wasTransited = statesMap['wasTransited'] ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Свернуто и открыто: $wasRestarted',
style: const TextStyle(fontSize: 18),
),
Text(
'Переход в банк: $wasTransited',
style: const TextStyle(fontSize: 18),
),
],
);
},
),
),
body: SafeArea(
child: ValueListenableBuilder(
valueListenable: _statesMapNotifier,
builder: (_, statesMap, __) {
final wasRestarted = statesMap['wasRestarted'] ?? false;
final wasTransited = statesMap['wasTransited'] ?? false;
if (wasRestarted && wasTransited) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Имитация запроса для проверки оплаты заказа...',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 21),
),
SizedBox(height: 16),
CircularProgressIndicator(),
],
),
),
);
}
return FutureBuilder<List<BankItem>>(
future: _getBankList(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final data = snapshot.data ?? [];
if (data.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Ошибка получения списка банков',
style: TextStyle(fontSize: 21),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _getBankList,
child: const Text(
'Повторить',
style: TextStyle(fontSize: 21),
),
)
],
),
);
}
return ListView.separated(
itemCount: data.length,
padding: const EdgeInsets.all(16),
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final bank = data[index];
return InkWell(
onTap: () => _openBank(context, schema: bank.schema),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Row(
children: [
Image.network(
bank.logoURL,
height: 40,
width: 40,
),
const SizedBox(width: 8),
Flexible(
child: Text(
bank.bankName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const Icon(Icons.arrow_forward_ios_rounded, size: 16),
],
),
);
},
);
},
);
},
),
),
);
}
Future<void> _openBank(BuildContext context, {required String schema}) async {
ScaffoldMessenger.of(context).removeCurrentSnackBar();
final paymentUrl = widget.paymentUrl.replaceAll(RegExp('https://'), '');
final link = '$schema://$paymentUrl';
try {
final wasLaunched = await launchUrlString(
link,
mode: LaunchMode.externalApplication,
);
if (!mounted) return;
_statesMapNotifier.value = {
'wasRestarted': false,
'wasTransited': wasLaunched,
};
} on Object {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Такого банка нет')),
);
}
}
Future<List<BankItem>> _getBankList() async {
try {
final response = await http.get(
Uri.parse('https://qr.nspk.ru/proxyapp/c2bmembers.json'),
);
final decodedMap = jsonDecode(response.body) as Map<String, dynamic>;
final bankList = decodedMap['dictionary'] as List;
final mappedList = <BankItem>[];
for (final item in bankList) {
final bankName = item['bankName'] as String?;
final logoURL = item['logoURL'] as String?;
final schema = item['schema'] as String?;
if (schema == null || logoURL == null || bankName == null) continue;
if (schema.isEmpty || logoURL.isEmpty || bankName.isEmpty) continue;
mappedList.add(
BankItem(
bankName: utf8.decode(bankName.codeUnits),
logoURL: logoURL,
schema: schema,
),
);
}
return mappedList;
} on Object {
return <BankItem>[];
}
}
}
class BankItem {
const BankItem({
required this.bankName,
required this.logoURL,
required this.schema,
});
final String bankName;
final String logoURL;
final String schema;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BankItem &&
runtimeType == other.runtimeType &&
bankName == other.bankName &&
logoURL == other.logoURL &&
schema == other.schema;
@override
int get hashCode => bankName.hashCode ^ logoURL.hashCode ^ schema.hashCode;
}
Write in the comments how you integrate SBP, share your feedback and experience.