LCOV - code coverage report
Current view: top level - lib/pages/integration/widgets - fints_widget.dart Coverage Total Hit
Test: lcov.info Lines: 0.6 % 181 1
Test Date: 2026-11-14 15:08:11 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 'dart:async';
       5              : 
       6              : import 'package:app_finance/_classes/herald/app_design.dart';
       7              : import 'package:app_finance/_classes/herald/app_locale.dart';
       8              : import 'package:app_finance/_classes/integration/fints/fints_importer.dart';
       9              : import 'package:app_finance/_classes/integration/fints/fints_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/fints_dialogs.dart';
      20              : import 'package:app_finance/pages/integration/widgets/integration_account_card.dart';
      21              : import 'package:fints/fints.dart';
      22              : import 'package:flutter/foundation.dart';
      23              : import 'package:material_ui/material_ui.dart';
      24              : import 'package:provider/provider.dart';
      25              : 
      26              : typedef FinTsClientFactory = FinTsClient Function(FinTsSettings settings, String pin, FinTsClientOptions options);
      27              : 
      28              : class FinTsBankAccount extends IntegrationTarget {
      29              :   final Account account;
      30              :   final String key;
      31              :   Balance? balance;
      32              : 
      33            0 :   FinTsBankAccount(this.account, {super.appAccount, super.category, required super.from})
      34            0 :       : key = FinTsSettings.accountKey(account);
      35              : 
      36            0 :   String get title {
      37            0 :     final name = account.productName ?? account.ownerName ?? '';
      38            0 :     final number = account.iban ?? account.accountNumber ?? '';
      39            0 :     final masked = number.length > 4 ? '…${number.substring(number.length - 4)}' : number;
      40            0 :     return [name, masked].where((e) => e.isNotEmpty).join(' ');
      41              :   }
      42              : }
      43              : 
      44              : class FinTsWidget extends StatefulWidget {
      45              :   final FinTsClientFactory? clientFactory;
      46              :   final bool isWeb;
      47              : 
      48            1 :   const FinTsWidget({super.key, this.clientFactory, this.isWeb = kIsWeb});
      49              : 
      50            0 :   @override
      51            0 :   FinTsWidgetState createState() => FinTsWidgetState();
      52              : }
      53              : 
      54              : class FinTsWidgetState extends State<FinTsWidget> {
      55              :   late final FinTsSettings settings;
      56              :   late final FinTsDialogs dialogs;
      57              :   late final TextEditingController url;
      58              :   late final TextEditingController bankCode;
      59              :   late final TextEditingController userId;
      60              :   late final TextEditingController customerId;
      61              :   late final TextEditingController productId;
      62              :   FinTsClient? client;
      63              :   List<FinTsBankAccount> accounts = [];
      64              :   bool isLoading = false;
      65              :   String? error;
      66              :   String? notice;
      67              : 
      68            0 :   @override
      69              :   void initState() {
      70            0 :     super.initState();
      71            0 :     settings = FinTsSettings.load();
      72            0 :     dialogs = FinTsDialogs(() => context);
      73            0 :     url = TextEditingController(text: settings.url);
      74            0 :     bankCode = TextEditingController(text: settings.bankCode);
      75            0 :     userId = TextEditingController(text: settings.userId);
      76            0 :     customerId = TextEditingController(text: settings.customerId);
      77            0 :     productId = TextEditingController(text: settings.productId);
      78              :   }
      79              : 
      80            0 :   @override
      81              :   void dispose() {
      82            0 :     unawaited(_close());
      83            0 :     for (final controller in [url, bankCode, userId, customerId, productId]) {
      84            0 :       controller.dispose();
      85              :     }
      86            0 :     super.dispose();
      87              :   }
      88              : 
      89            0 :   Future<void> _close() async {
      90            0 :     final current = client;
      91            0 :     client = null;
      92              :     if (current == null) {
      93              :       return;
      94              :     }
      95              :     try {
      96            0 :       await current.close();
      97              :     } finally {
      98            0 :       await FinTsSettings.saveState(current.state);
      99              :     }
     100              :   }
     101              : 
     102            0 :   FinTsClient _createClient(String pin) {
     103            0 :     final options = FinTsClientOptions(
     104            0 :       state: FinTsSettings.loadState(),
     105            0 :       tanCallback: dialogs.askTan,
     106            0 :       tanMethodSelector: dialogs.selectTanMethod,
     107            0 :       tanMediumSelector: dialogs.selectTanMedium,
     108            0 :       onBankMessage: (message) => _notify('${message.subject}: ${message.text}'),
     109            0 :       onWarning: (warning) => _notify('${warning.code}: ${warning.text}'),
     110              :     );
     111            0 :     return widget.clientFactory?.call(settings, pin, options) ??
     112            0 :         FinTs4Client(config: settings.toConfig(), pin: pin, options: options, namespaces: settings.namespace);
     113              :   }
     114              : 
     115            0 :   void _notify(String text) {
     116            0 :     if (mounted) {
     117            0 :       ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 8)));
     118              :     }
     119              :   }
     120              : 
     121            0 :   Future<void> _run(Future<void> Function() callback) async {
     122            0 :     setState(() {
     123            0 :       isLoading = true;
     124            0 :       error = null;
     125            0 :       notice = null;
     126              :     });
     127              :     try {
     128            0 :       await callback();
     129            0 :     } on FinTsLoginDataChangedException catch (e) {
     130            0 :       error = AppLocale.labels.finTsLoginChanged(e.message);
     131            0 :       await _close();
     132            0 :     } on FinTsException catch (e) {
     133            0 :       error = e.message;
     134            0 :     } on ArgumentError catch (e) {
     135            0 :       error = e.message?.toString() ?? e.toString();
     136              :     } catch (e) {
     137            0 :       error = e.toString();
     138              :     } finally {
     139            0 :       final current = client;
     140              :       if (current != null) {
     141            0 :         await FinTsSettings.saveState(current.state);
     142              :       }
     143            0 :       if (mounted) {
     144            0 :         setState(() => isLoading = false);
     145              :       }
     146              :     }
     147              :   }
     148              : 
     149            0 :   Future<void> saveSettings() async {
     150            0 :     settings
     151            0 :       ..url = url.text.trim()
     152            0 :       ..bankCode = bankCode.text.trim()
     153            0 :       ..userId = userId.text.trim()
     154            0 :       ..customerId = customerId.text.trim()
     155            0 :       ..productId = productId.text.trim();
     156            0 :     await settings.save();
     157              :   }
     158              : 
     159            0 :   Future<void> connect() => _run(() async {
     160            0 :         await saveSettings();
     161            0 :         if (!settings.isComplete) {
     162            0 :           throw ArgumentError(AppLocale.labels.finTsIncomplete);
     163              :         }
     164            0 :         settings.toConfig();
     165            0 :         final pin = await dialogs.askPin();
     166              :         if (pin == null) {
     167              :           return;
     168              :         }
     169            0 :         await _close();
     170            0 :         final current = _createClient(pin);
     171            0 :         final refusal = current.loginRefusal;
     172            0 :         if (refusal is FinTsLoginDataChangedException) {
     173              :           throw refusal;
     174              :         } else if (refusal != null) {
     175            0 :           if (!await dialogs.confirm(AppLocale.labels.finTsLoginRefused(refusal.message))) {
     176            0 :             await current.close();
     177              :             return;
     178              :           }
     179            0 :           await current.clearLoginRefusal(pin);
     180              :         }
     181            0 :         client = current;
     182            0 :         final list = await current.getAccounts();
     183            0 :         final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
     184            0 :         final monthAgo = DateTime.now().subtract(const Duration(days: 30));
     185            0 :         accounts = list.map((account) {
     186            0 :           final key = FinTsSettings.accountKey(account);
     187            0 :           return FinTsBankAccount(
     188              :             account,
     189            0 :             appAccount: settings.links[key],
     190              :             category: defaultBudget,
     191            0 :             from: settings.imported[key] ?? monthAgo,
     192              :           );
     193            0 :         }).toList();
     194            0 :         for (final item in accounts) {
     195              :           try {
     196            0 :             item.balance = await current.getBalance(item.account);
     197            0 :           } on FinTsUnsupportedException {
     198              :             // The bank offers no balance for this account
     199              :           }
     200              :         }
     201            0 :         if (accounts.isEmpty) {
     202            0 :           notice = AppLocale.labels.integrationNoAccounts;
     203              :         }
     204              :       });
     205              : 
     206            0 :   Future<void> disconnect() => _run(() async {
     207            0 :         await _close();
     208            0 :         accounts = [];
     209              :       });
     210              : 
     211            0 :   Future<void> importTransactions(FinTsBankAccount item) => _run(() async {
     212            0 :         final current = client;
     213              :         if (current == null) {
     214              :           return;
     215              :         }
     216            0 :         if (item.appAccount == null || item.category == null) {
     217            0 :           throw ArgumentError(AppLocale.labels.integrationSelectTarget);
     218              :         }
     219            0 :         final state = Provider.of<AppData>(context, listen: false);
     220              :         List<Transaction> transactions;
     221              :         try {
     222            0 :           transactions = await current.getTransactions(item.account, from: item.from);
     223            0 :         } on FinTsIncompleteResultException<List<Transaction>> catch (e) {
     224            0 :           transactions = e.partialResult ?? const [];
     225            0 :           _notify(e.feedback.map((f) => '${f.code}: ${f.text}').join('\n'));
     226              :         }
     227            0 :         final importer = FinTsImporter(
     228            0 :           exists: (uuid) => state.getByUuid(uuid) != null,
     229            0 :           add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
     230              :         );
     231            0 :         final result = importer.run(
     232            0 :           accountKey: item.key,
     233              :           transactions: transactions,
     234            0 :           account: item.appAccount!,
     235            0 :           category: item.category!,
     236              :         );
     237            0 :         await state.restate();
     238            0 :         final today = DateTime.now();
     239            0 :         settings.links[item.key] = item.appAccount!;
     240            0 :         settings.imported[item.key] = DateTime(today.year, today.month, today.day);
     241            0 :         await settings.save();
     242            0 :         notice = AppLocale.labels.integrationImported(result.added, result.pending, result.skipped);
     243              :       });
     244              : 
     245            0 :   Widget buildSettings(BuildContext context) {
     246            0 :     final isConnected = client != null;
     247            0 :     return Column(
     248            0 :       crossAxisAlignment: AppDesign.getAlignment(),
     249            0 :       children: [
     250            0 :         InputWrapper.text(
     251            0 :           title: AppLocale.labels.finTsUrl,
     252              :           tooltip: 'https://…',
     253            0 :           controller: url,
     254              :           isRequired: true,
     255              :           isDisabled: isConnected,
     256              :           inputType: TextInputType.url,
     257              :         ),
     258            0 :         InputWrapper.text(
     259            0 :           title: AppLocale.labels.finTsBankCode,
     260            0 :           controller: bankCode,
     261              :           isRequired: true,
     262              :           isDisabled: isConnected,
     263              :           inputType: TextInputType.number,
     264              :         ),
     265            0 :         InputWrapper.text(
     266            0 :           title: AppLocale.labels.finTsUserId,
     267            0 :           controller: userId,
     268              :           isRequired: true,
     269              :           isDisabled: isConnected,
     270              :         ),
     271            0 :         InputWrapper.text(
     272            0 :           title: AppLocale.labels.finTsCustomerId,
     273            0 :           controller: customerId,
     274              :           isDisabled: isConnected,
     275              :         ),
     276            0 :         InputWrapper.text(
     277            0 :           title: AppLocale.labels.finTsProductId,
     278            0 :           tooltip: AppLocale.labels.finTsProductIdTooltip,
     279            0 :           controller: productId,
     280              :           isRequired: true,
     281              :           isDisabled: isConnected,
     282              :         ),
     283            0 :         InputWrapper.select(
     284            0 :           title: AppLocale.labels.finTsVersion,
     285            0 :           value: settings.version,
     286              :           isDisabled: isConnected,
     287            0 :           options: <ListSelectorItem>[
     288            0 :             for (final version in FinTsSettings.namespaces.keys) ListSelectorItem(id: version, name: 'FinTS $version'),
     289              :           ],
     290            0 :           onChange: (value) => setState(() => settings.version = value ?? settings.version),
     291              :         ),
     292              :       ],
     293              :     );
     294              :   }
     295              : 
     296            0 :   Widget buildAccount(BuildContext context, FinTsBankAccount item, AppData state) {
     297            0 :     final balance = item.balance?.booked;
     298            0 :     return IntegrationAccountCard(
     299            0 :       title: item.title,
     300            0 :       subtitle: balance != null ? '${AppLocale.labels.balance}: ${balance.value} ${balance.currency}' : null,
     301              :       target: item,
     302              :       state: state,
     303            0 :       isDisabled: isLoading,
     304            0 :       onChanged: () => setState(() {}),
     305            0 :       onImport: () => importTransactions(item),
     306              :     );
     307              :   }
     308              : 
     309            0 :   @override
     310              :   Widget build(BuildContext context) {
     311            0 :     final textTheme = context.textTheme;
     312            0 :     final colorScheme = context.colorScheme;
     313            0 :     if (widget.isWeb) {
     314            0 :       return Text(AppLocale.labels.finTsWebUnsupported, style: textTheme.bodyLarge);
     315              :     }
     316            0 :     return Consumer<AppData>(builder: (context, state, _) {
     317            0 :       return Column(
     318            0 :         crossAxisAlignment: AppDesign.getAlignment(),
     319            0 :         children: [
     320            0 :           Text(AppLocale.labels.finTsDescription, style: textTheme.bodyMedium),
     321              :           ThemeHelper.hIndent2x,
     322            0 :           buildSettings(context),
     323              :           ThemeHelper.hIndent2x,
     324            0 :           if (error != null) ...[
     325            0 :             Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
     326              :             ThemeHelper.hIndent,
     327              :           ],
     328            0 :           if (notice != null) ...[
     329            0 :             Text(notice!, style: textTheme.bodyLarge),
     330              :             ThemeHelper.hIndent,
     331              :           ],
     332            0 :           if (isLoading)
     333            0 :             const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
     334              :           else
     335            0 :             client == null
     336            0 :                 ? FullSizedButtonWidget(
     337              :                     key: const ValueKey('fints_connect'),
     338              :                     icon: Icons.login,
     339            0 :                     onPressed: connect,
     340            0 :                     title: AppLocale.labels.integrationConnect,
     341              :                   )
     342            0 :                 : FullSizedButtonWidget(
     343              :                     key: const ValueKey('fints_disconnect'),
     344              :                     icon: Icons.logout,
     345            0 :                     onPressed: disconnect,
     346            0 :                     title: AppLocale.labels.integrationDisconnect,
     347              :                   ),
     348            0 :           for (final item in accounts) buildAccount(context, item, state),
     349              :         ],
     350              :       );
     351              :     });
     352              :   }
     353              : }
        

Generated by: LCOV version 2.0-1