LCOV - code coverage report
Current view: top level - lib/pages/integration/widgets - enablebanking_widget.dart Coverage Total Hit
Test: lcov.info Lines: 92.7 % 205 190
Test Date: 2026-11-14 15:04:37 Functions: - 0 0

            Line data    Source code
       1              : // Copyright 2026 The terCAD team. All rights reserved.
       2              : // Use of this source code is governed by a CC BY-NC-ND 4.0 license that can be found in the LICENSE file.
       3              : 
       4              : import 'package:app_finance/_classes/controller/deep_link/deep_link_handler.dart';
       5              : import 'package:app_finance/_classes/herald/app_design.dart';
       6              : import 'package:app_finance/_classes/herald/app_locale.dart';
       7              : import 'package:app_finance/_classes/integration/enablebanking/enablebanking_client.dart';
       8              : import 'package:app_finance/_classes/integration/enablebanking/enablebanking_importer.dart';
       9              : import 'package:app_finance/_classes/integration/enablebanking/enablebanking_settings.dart';
      10              : import 'package:app_finance/_classes/storage/app_data.dart';
      11              : import 'package:app_finance/_classes/storage/app_preferences.dart';
      12              : import 'package:app_finance/_classes/storage/transaction_log.dart';
      13              : import 'package:app_finance/_configs/theme_helper.dart';
      14              : import 'package:app_finance/_ext/build_context_ext.dart';
      15              : import 'package:app_finance/design/button/full_sized_button_widget.dart';
      16              : import 'package:app_finance/design/form/list_selector_item.dart';
      17              : import 'package:app_finance/design/generic/loading_widget.dart';
      18              : import 'package:app_finance/design/wrapper/input_wrapper.dart';
      19              : import 'package:app_finance/pages/integration/widgets/integration_account_card.dart';
      20              : import 'package:http/http.dart' as http;
      21              : import 'package:material_ui/material_ui.dart';
      22              : import 'package:provider/provider.dart';
      23              : import 'package:url_launcher/url_launcher.dart';
      24              : import 'package:uuid/uuid.dart';
      25              : 
      26              : typedef UrlLauncher = Future<void> Function(Uri url);
      27              : 
      28              : class EnableBankingBankAccount extends IntegrationTarget {
      29              :   final EnableBankingAccount account;
      30              :   EnableBankingBalance? balance;
      31              : 
      32            2 :   EnableBankingBankAccount(this.account, {super.appAccount, super.category, required super.from});
      33              : 
      34            2 :   String get title {
      35            4 :     final number = account.iban ?? '';
      36           12 :     final masked = number.length > 4 ? '…${number.substring(number.length - 4)}' : number;
      37           14 :     return [account.name, masked].where((e) => e.isNotEmpty).join(' ');
      38              :   }
      39              : }
      40              : 
      41              : class EnableBankingWidget extends StatefulWidget {
      42              :   final http.Client? httpClient;
      43              :   final UrlLauncher? launcher;
      44              : 
      45            4 :   const EnableBankingWidget({super.key, this.httpClient, this.launcher});
      46              : 
      47            2 :   @override
      48            2 :   EnableBankingWidgetState createState() => EnableBankingWidgetState();
      49              : }
      50              : 
      51              : class EnableBankingWidgetState extends State<EnableBankingWidget> {
      52              :   late final EnableBankingSettings settings;
      53              :   late final TextEditingController applicationId;
      54              :   late final TextEditingController redirectUrl;
      55              :   late final TextEditingController country;
      56              :   final privateKey = TextEditingController();
      57              :   final code = TextEditingController();
      58              :   List<EnableBankingAspsp> aspsps = [];
      59              :   EnableBankingSession? session;
      60              :   String? authorizationState;
      61              :   List<EnableBankingBankAccount> accounts = [];
      62              :   bool isLoading = false;
      63              :   String? error;
      64              :   String? notice;
      65              : 
      66            2 :   @override
      67              :   void initState() {
      68            2 :     super.initState();
      69            4 :     settings = EnableBankingSettings.load();
      70            4 :     session = EnableBankingSettings.loadSession();
      71           10 :     authorizationState = settings.authorizationState.isEmpty ? null : settings.authorizationState;
      72            8 :     applicationId = TextEditingController(text: settings.applicationId);
      73            4 :     redirectUrl = TextEditingController(
      74            8 :       text: settings.redirectUrl.isEmpty ? EnableBankingSettings.defaultRedirectUrl : settings.redirectUrl,
      75              :     );
      76            8 :     country = TextEditingController(text: settings.country);
      77            6 :     DeepLinkHandler.enableBankingCallback.addListener(onLink);
      78              :     // A callback that came before this section was open (e.g. the application was restarted meanwhile)
      79            8 :     WidgetsBinding.instance.addPostFrameCallback((_) => onLink());
      80              :   }
      81              : 
      82            2 :   @override
      83              :   void dispose() {
      84            6 :     DeepLinkHandler.enableBankingCallback.removeListener(onLink);
      85           14 :     for (final controller in [applicationId, redirectUrl, country, privateKey, code]) {
      86            2 :       controller.dispose();
      87              :     }
      88            2 :     super.dispose();
      89              :   }
      90              : 
      91            2 :   Future<void> _run(Future<void> Function(EnableBankingClient client) callback) async {
      92            4 :     setState(() {
      93            2 :       isLoading = true;
      94            2 :       error = null;
      95            2 :       notice = null;
      96              :     });
      97              :     EnableBankingClient? client;
      98              :     try {
      99            2 :       await saveSettings();
     100            2 :       final key = EnableBankingSettings.loadKey();
     101           18 :       if (settings.applicationId.isEmpty || key == null || settings.redirectUrl.isEmpty || settings.country.isEmpty) {
     102            0 :         throw EnableBankingException(AppLocale.labels.enableBankingIncomplete);
     103              :       }
     104            2 :       client = EnableBankingClient(
     105            4 :         applicationId: settings.applicationId,
     106              :         privateKey: key,
     107            4 :         client: widget.httpClient ?? http.Client(),
     108              :       );
     109            2 :       await callback(client);
     110            1 :     } on EnableBankingException catch (e) {
     111            2 :       error = e.message;
     112              :     } catch (e) {
     113            0 :       error = e.toString();
     114              :     } finally {
     115            4 :       if (widget.httpClient == null) {
     116            0 :         client?.close();
     117              :       }
     118            2 :       if (mounted) {
     119            6 :         setState(() => isLoading = false);
     120              :       }
     121              :     }
     122              :   }
     123              : 
     124            2 :   Future<void> saveSettings() async {
     125            6 :     final key = privateKey.text.trim();
     126            2 :     if (key.isNotEmpty) {
     127            1 :       EnableBankingClient.parsePrivateKey(key);
     128            2 :       await EnableBankingSettings.saveKey(EnableBankingClient.normalizePrivateKey(key));
     129            2 :       privateKey.clear();
     130              :     }
     131            2 :     settings
     132            8 :       ..applicationId = applicationId.text.trim()
     133            8 :       ..redirectUrl = redirectUrl.text.trim()
     134           10 :       ..country = country.text.trim().toUpperCase();
     135            4 :     await settings.save();
     136              :   }
     137              : 
     138            2 :   void onLink() {
     139            7 :     if (!mounted || DeepLinkHandler.enableBankingCallback.value == null || authorizationState == null) {
     140              :       return;
     141              :     }
     142            1 :     if (isLoading) {
     143            0 :       Future.delayed(const Duration(milliseconds: 300), onLink);
     144              :       return;
     145              :     }
     146            3 :     confirm(link: DeepLinkHandler.takeEnableBankingCallback().toString());
     147              :   }
     148              : 
     149            2 :   Future<void> _setAuthorizationState(String? state) async {
     150            2 :     authorizationState = state;
     151            4 :     settings.authorizationState = state ?? '';
     152            4 :     await settings.save();
     153              :   }
     154              : 
     155            2 :   EnableBankingAspsp? get _aspsp {
     156           11 :     final selected = aspsps.where((e) => e.name == settings.aspsp).firstOrNull;
     157            3 :     if (selected != null || settings.aspsp.isEmpty) {
     158              :       return selected;
     159              :     }
     160            5 :     return EnableBankingAspsp(name: settings.aspsp, country: settings.country);
     161              :   }
     162              : 
     163            3 :   Future<void> loadBanks() => _run((client) async {
     164            4 :         aspsps = await client.getAspsps(settings.country);
     165            2 :         if (aspsps.isEmpty) {
     166            0 :           notice = AppLocale.labels.integrationNoAccounts;
     167              :         }
     168              :       });
     169              : 
     170            6 :   Future<void> connect() => _run((client) async {
     171            2 :         final current = session;
     172            0 :         if (current != null && current.isValid) {
     173            0 :           return _loadAccounts(client, current);
     174              :         }
     175            2 :         final aspsp = _aspsp;
     176              :         if (aspsp == null) {
     177            0 :           throw EnableBankingException(AppLocale.labels.enableBankingBank);
     178              :         }
     179            2 :         final state = const Uuid().v4();
     180            6 :         final url = await client.startAuthorization(aspsp: aspsp, redirectUrl: settings.redirectUrl, state: state);
     181            2 :         await _setAuthorizationState(state);
     182            6 :         await (widget.launcher ?? (uri) => launchUrl(uri, mode: LaunchMode.externalApplication))(url);
     183              :       });
     184              : 
     185              :   // [link] comes from the bank's redirect (deep link) and has to return the pending state; the field is entered manually
     186            6 :   Future<void> confirm({String? link}) => _run((client) async {
     187            2 :         final value = EnableBankingClient.extractCode(
     188            2 :           link ?? code.text,
     189            2 :           state: authorizationState,
     190              :           requireState: link != null,
     191              :         );
     192            2 :         final created = await client.createSession(value);
     193            2 :         await EnableBankingSettings.saveSession(created);
     194            2 :         session = created;
     195            2 :         await _setAuthorizationState(null);
     196            4 :         code.clear();
     197            2 :         await _loadAccounts(client, created);
     198              :       });
     199              : 
     200            2 :   Future<void> _loadAccounts(EnableBankingClient client, EnableBankingSession current) async {
     201            2 :     final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
     202            4 :     final monthAgo = DateTime.now().subtract(const Duration(days: 30));
     203            4 :     accounts = current.accounts
     204            6 :         .map((account) => EnableBankingBankAccount(
     205              :               account,
     206            8 :               appAccount: settings.links[account.key],
     207              :               category: defaultBudget,
     208            8 :               from: settings.imported[account.key] ?? monthAgo,
     209              :             ))
     210            2 :         .toList();
     211            4 :     for (final item in accounts) {
     212              :       try {
     213           10 :         item.balance = (await client.getBalances(item.account.uid)).firstOrNull;
     214            0 :       } on EnableBankingException {
     215              :         // Some banks offer no balances
     216              :       }
     217              :     }
     218            4 :     if (accounts.isEmpty) {
     219            0 :       notice = AppLocale.labels.integrationNoAccounts;
     220              :     }
     221              :   }
     222              : 
     223            0 :   Future<void> disconnect() async => setState(() => accounts = []);
     224              : 
     225            3 :   Future<void> forget() => _run((client) async {
     226            1 :         final current = session;
     227            1 :         session = null;
     228            2 :         accounts = [];
     229            1 :         await EnableBankingSettings.clearSession();
     230              :         if (current != null) {
     231            2 :           await client.deleteSession(current.id);
     232              :         }
     233              :       });
     234              : 
     235            3 :   Future<void> importTransactions(EnableBankingBankAccount item) => _run((client) async {
     236            2 :         if (item.appAccount == null || item.category == null) {
     237            0 :           throw EnableBankingException(AppLocale.labels.integrationSelectTarget);
     238              :         }
     239            2 :         final state = Provider.of<AppData>(context, listen: false);
     240            4 :         final transactions = await client.getTransactions(item.account.uid, from: item.from);
     241            1 :         final importer = EnableBankingImporter(
     242            2 :           exists: (uuid) => state.getByUuid(uuid) != null,
     243            3 :           add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
     244              :         );
     245            1 :         final summary = importer.run(
     246            2 :           accountKey: item.account.key,
     247              :           transactions: transactions,
     248            1 :           account: item.appAccount!,
     249            1 :           category: item.category!,
     250              :         );
     251            1 :         await state.restate();
     252            1 :         final today = DateTime.now();
     253            6 :         settings.links[item.account.key] = item.appAccount!;
     254            9 :         settings.imported[item.account.key] = DateTime(today.year, today.month, today.day);
     255            2 :         await settings.save();
     256            3 :         notice = AppLocale.labels.integrationImported(summary.added, summary.pending, summary.skipped);
     257              :       });
     258              : 
     259            2 :   Widget buildSettings(BuildContext context) {
     260            4 :     final banks = aspsps.isNotEmpty
     261            1 :         ? aspsps
     262           13 :         : [if (settings.aspsp.isNotEmpty) EnableBankingAspsp(name: settings.aspsp, country: settings.country)];
     263            2 :     return Column(
     264            2 :       crossAxisAlignment: AppDesign.getAlignment(),
     265            2 :       children: [
     266            2 :         InputWrapper.text(
     267            4 :           title: AppLocale.labels.enableBankingAppId,
     268            2 :           controller: applicationId,
     269              :           isRequired: true,
     270              :         ),
     271            2 :         InputWrapper.text(
     272            4 :           title: AppLocale.labels.enableBankingKey,
     273            4 :           tooltip: AppLocale.labels.enableBankingKeyTooltip,
     274            2 :           controller: privateKey,
     275            2 :           isRequired: EnableBankingSettings.loadKey() == null,
     276              :         ),
     277            2 :         InputWrapper.text(
     278            4 :           title: AppLocale.labels.enableBankingRedirect,
     279            4 :           tooltip: AppLocale.labels.enableBankingRedirectTooltip,
     280            2 :           controller: redirectUrl,
     281              :           isRequired: true,
     282              :           inputType: TextInputType.url,
     283              :         ),
     284            2 :         InputWrapper.text(
     285            4 :           title: AppLocale.labels.enableBankingCountry,
     286            2 :           controller: country,
     287              :           isRequired: true,
     288              :         ),
     289            2 :         if (banks.isNotEmpty)
     290            2 :           InputWrapper.select(
     291            4 :             title: AppLocale.labels.enableBankingBank,
     292           10 :             value: settings.aspsp.isEmpty ? null : settings.aspsp,
     293              :             isRequired: true,
     294           10 :             options: <ListSelectorItem>[for (final bank in banks) ListSelectorItem(id: bank.name, name: bank.name)],
     295            0 :             onChange: (value) => setState(() => settings.aspsp = value ?? ''),
     296              :           ),
     297            2 :         Align(
     298              :           alignment: Alignment.centerRight,
     299            2 :           child: TextButton.icon(
     300              :             key: const ValueKey('enablebanking_banks'),
     301              :             icon: const Icon(Icons.account_balance),
     302            4 :             onPressed: isLoading ? null : loadBanks,
     303            6 :             label: Text(AppLocale.labels.enableBankingLoadBanks),
     304              :           ),
     305              :         ),
     306              :       ],
     307              :     );
     308              :   }
     309              : 
     310            2 :   Widget buildAccount(BuildContext context, EnableBankingBankAccount item, AppData state) {
     311            2 :     final balance = item.balance;
     312            2 :     return IntegrationAccountCard(
     313            2 :       title: item.title,
     314            5 :       subtitle: balance != null ? '${AppLocale.labels.balance}: ${balance.amount} ${balance.currency}' : null,
     315              :       target: item,
     316              :       state: state,
     317            2 :       isDisabled: isLoading,
     318            0 :       onChanged: () => setState(() {}),
     319            2 :       onImport: () => importTransactions(item),
     320              :     );
     321              :   }
     322              : 
     323            2 :   @override
     324              :   Widget build(BuildContext context) {
     325            2 :     final textTheme = context.textTheme;
     326            2 :     final colorScheme = context.colorScheme;
     327            4 :     return Consumer<AppData>(builder: (context, state, _) {
     328            2 :       return Column(
     329            2 :         crossAxisAlignment: AppDesign.getAlignment(),
     330            2 :         children: [
     331            8 :           Text(AppLocale.labels.enableBankingDescription, style: textTheme.bodyMedium),
     332              :           ThemeHelper.hIndent2x,
     333            4 :           if (session == null) buildSettings(context),
     334            2 :           if (authorizationState != null)
     335            2 :             InputWrapper.text(
     336            4 :               title: AppLocale.labels.enableBankingCode,
     337            4 :               tooltip: AppLocale.labels.enableBankingCodeTooltip,
     338            2 :               controller: code,
     339              :               isRequired: true,
     340              :               inputType: TextInputType.url,
     341              :             ),
     342            2 :           ThemeHelper.hIndent2x,
     343            3 :           if (error != null) ...[
     344            5 :             Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
     345              :             ThemeHelper.hIndent,
     346              :           ],
     347            3 :           if (notice != null) ...[
     348            3 :             Text(notice!, style: textTheme.bodyLarge),
     349              :             ThemeHelper.hIndent,
     350              :           ],
     351            2 :           if (isLoading)
     352            0 :             const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
     353            2 :           else ...[
     354            4 :             if (accounts.isNotEmpty)
     355            2 :               FullSizedButtonWidget(
     356              :                 key: const ValueKey('enablebanking_disconnect'),
     357              :                 icon: Icons.logout,
     358            2 :                 onPressed: disconnect,
     359            4 :                 title: AppLocale.labels.integrationDisconnect,
     360              :               )
     361              :             else
     362            2 :               FullSizedButtonWidget(
     363              :                 key: const ValueKey('enablebanking_connect'),
     364              :                 icon: Icons.login,
     365            6 :                 onPressed: authorizationState != null ? confirm : connect,
     366            4 :                 title: AppLocale.labels.integrationConnect,
     367              :               ),
     368            2 :             if (session != null)
     369            2 :               Align(
     370              :                 alignment: Alignment.centerRight,
     371            2 :                 child: TextButton.icon(
     372              :                   key: const ValueKey('enablebanking_forget'),
     373              :                   icon: const Icon(Icons.link_off),
     374            2 :                   onPressed: forget,
     375            6 :                   label: Text(AppLocale.labels.integrationForget),
     376              :                 ),
     377              :               ),
     378              :           ],
     379            4 :           for (final item in accounts) buildAccount(context, item, state),
     380              :         ],
     381              :       );
     382              :     });
     383              :   }
     384              : }
        

Generated by: LCOV version 2.0-1