Создаем Flutter-приложение для оплаты через СБП без натива

bc600bb1c138fdd21736e0891508c8b1.jpg

Всем привет! Меня зовут Мурат Насиров, я Flutter-разработчик в Friflex. Мы разрабатываем мобильные приложения для бизнеса и специализируемся на Flutter. 

Ранее я поделился своим опытом, как интегрировать СБП при помощи нативных решений НСПК (Национальной системы платежных карт). В этой статье рассказываю, как можно сделать это при помощи Flutter-приложения и двух пакетов из pub.dev.

Вспоминаем структуру

На сайте API СБП описано, как использовать их формат для оплаты по уникальной ссылке. Обычная ссылка для оплаты отправляет пользователя на сайт СБП, где из нее формируется QR-код. Формат обычной ссылки:

https://qr.nspk.ru/AS100001ORTF4GAF80KPJ53K186D9A3G? type=01&bank=100000000007&crc=0C8A

Неудобство QR-кода в том, что его нужно сканировать в приложении банка. После этого открываются окно оплаты и данные о приемщике платежа. 

1aa0255cd5abe3e2c9e15d3d701a43e4.jpg

У каждого банка, который подключен к СБП, есть своя схема. Это уникальный 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> _getBankList() async {
  try {
    final response = await http.get(
      Uri.parse('https://qr.nspk.ru/proxyapp/c2bmembers.json'),
    );
    final decodedMap = jsonDecode(response.body) as Map;
    final bankList = decodedMap['dictionary'] as List;
    final mappedList = [];

    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 [];
  }
}

Здесь используем utf8 из-за проблем с кодировкой. Если у банка нет схемы, имени или картинки, не добавляем его в список. Чтобы отобразить список банков, используем FutureBuilder и ListView.

Жизненный цикл нашего приложения меняется, когда оно сворачивается или из него открывается другое приложение. Чтобы отслеживать состояние жизненного цикла, используем WidgetsBindingObserver вместе с AppLifecycleListener. 

Теперь создаем Map с двумя флагами, в котором будут храниться два состояния: состояние сворачивания, а затем открытия приложения (при onRestart) и состояние перехода в приложение банка по диплинку. Map оборачиваем в ValueNotifier, так как необходимо отслеживать состояние, когда оба флага true.

class _SbpPayScreenState extends State
    with WidgetsBindingObserver {
  late final ValueNotifier> _statesMapNotifier;
  late final AppLifecycleListener _lifecycleListener;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _statesMapNotifier = ValueNotifier>(
      {'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 _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 обновляется каждый раз, когда приложение сворачивают и открывают повторно. Так мы отслеживаем момент, когда пользователь переходит в приложение банка и обратно.

Приложение должно работать вот так.

64fe152786af8a419fc776a2eb280c3c.gifПолный код приложения

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 createState() => _SbpPayScreenState();
}

class _SbpPayScreenState extends State
    with WidgetsBindingObserver {
  late final ValueNotifier> _statesMapNotifier;
  late final AppLifecycleListener _lifecycleListener;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    _statesMapNotifier = ValueNotifier>(
      {'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>(
              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 _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> _getBankList() async {
    try {
      final response = await http.get(
        Uri.parse('https://qr.nspk.ru/proxyapp/c2bmembers.json'),
      );

      final decodedMap = jsonDecode(response.body) as Map;
      final bankList = decodedMap['dictionary'] as List;
      final mappedList = [];

      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 [];
    }
  }
}

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;
}

Пишите в комментариях, как вы интегрируете СБП, делитесь отзывами и опытом.

© Habrahabr.ru