Всем привет! Меня зовут Мурат Насиров, я Flutter-разработчик в Friflex. Мы разрабатываем мобильные приложения для бизнеса и специализируемся на Flutter.
Ранее я поделился своим опытом, как интегрировать СБП при помощи нативных решений НСПК (Национальной системы платежных карт). В этой статье рассказываю, как можно сделать это при помощи Flutter-приложения и двух пакетов из pub.dev.
Вспоминаем структуру
На сайте API СБП описано, как использовать их формат для оплаты по уникальной ссылке. Обычная ссылка для оплаты отправляет пользователя на сайт СБП, где из нее формируется QR-код. Формат обычной ссылки:
https://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G? type=01&bank=100000000007&crc=0C8A
Неудобство QR-кода в том, что его нужно сканировать в приложении банка. После этого открываются окно оплаты и данные о приемщике платежа.
У каждого банка, который подключен к СБП, есть своя схема. Это уникальный id. Он необходим, чтобы перейти в банковское приложение. Полный список банков, которые используют СБП, можно найти здесь.
Если мы возьмем поле schema
из ссылки выше и вставим его в ссылку оплаты вместо https
, то получим диплинк, который сразу будет открывать приложение нужного банка. Например, ссылка для Банка Открытие будет выглядеть вот так:
bank100000000015://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G? type=01&bank=100000000007&crc=0C8A
Банки, чьи приложения были удалены из App Store и Google Play, могут создавать клоны. У клонов отличаются package_name
, а поле schema
остается как у оригинала. Поэтому, если у пользователя вместо оригинального приложения СберБанк стоит аналог, например, Умный Онлайн, оплата по СБП будет проходить одинаково.
Пишем приложение
Переходим к созданию приложения. Делаем это в несколько шагов:
1. Получаем список банков.
2. Формируем список банков. Для этого достаточно трех полей: bankName
, logoURL
и schema
.
3. Вставляем эти поля в объект. Создаем список, в котором отображаются все банки на экране.
4. Ставим слушатель нажатия (GestureDetector
) на каждый банк. Меняем в ссылках оплаты https
на schema
соответствующего банка.
5. Прослушиваем жизненный цикл приложения с помощью AppLifecycleListener
. Используем коллбек onRestart
, в нем ставим флаг. Второй флаг ставим после перехода в приложение банка.
6. После возвращения из приложения банка отправляем запрос на проверку статуса оплаты.
В проекте нам понадобится всего два пакета. В pubspec.yaml
указываем:
dependencies:
http: any
url_launcher: any
Версию выбираем в зависимости от нашего Flutter SDK. Настраиваем получение списка банков. Создаем объект для полей: bankName
, logoURL
и 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;
}
Запрашиваем список банков-участников СБП:
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>[];
}
}
Здесь используем utf8
из-за проблем с кодировкой. Если у банка нет схемы, имени или картинки, не добавляем его в список. Чтобы отобразить список банков, используем FutureBuilder
и ListView
.
Жизненный цикл нашего приложения меняется, когда оно сворачивается или из него открывается другое приложение. Чтобы отслеживать состояние жизненного цикла, используем WidgetsBindingObserver
вместе с AppLifecycleListener.
Теперь создаем Map
с двумя флагами, в котором будут храниться два состояния: состояние сворачивания, а затем открытия приложения (при onRestart
) и состояние перехода в приложение банка по диплинку. Map
оборачиваем в ValueNotifier
, так как необходимо отслеживать состояние, когда оба флага 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();
}
...
}
В Map
используем два ключа:
wasRestarted
— флаг, когда приложение было свернуто и открыто снова;wasTransited
— флаг, когда произошел переход в приложение банка.
Если оба флага стали true
, значит пользователь перешел в приложение банка. Чтобы узнать, оплатил ли он, мы запрашиваем информацию у бэкенда. Для этого у _statesMapNotifier
стоит слушатель с имитацией события запроса данных.
Можно придумать свою логику, я показываю один из вариантов, как это можно отслеживать. В build
добавляем ValueListenableBuilder
. Он ставит состояние загрузки.
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(),
],
),
),
);
}
...
}
)
Создаем метод открытия банка. Для каждого элемента из списка банков нужно добавить слушатель нажатий. Из элемента берем schema
, которая в ссылке заменяет 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('Такого банка нет')),
);
}
}
Когда диплинк открылся (wasLaunched
стал true
), флаг wasRestarted
становится false
. wasRestarted
обновляется каждый раз, когда приложение сворачивают и открывают повторно. Так мы отслеживаем момент, когда пользователь переходит в приложение банка и обратно.
Приложение должно работать вот так.
Полный код приложения
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;
}
Пишите в комментариях, как вы интегрируете СБП, делитесь отзывами и опытом.