We create a Flutter application for payment via SBP without native

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.

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_nameand 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 ValueNotifiersince 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 schemawhich 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.

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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *