LCOV - code coverage report
Current view: top level - lib/pages/integration/widgets - simplefin_widget.dart Coverage Total Hit
Test: lcov.info Lines: 0.9 % 114 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 'package:app_finance/_classes/herald/app_design.dart';
       5              : import 'package:app_finance/_classes/herald/app_locale.dart';
       6              : import 'package:app_finance/_classes/integration/simplefin/simplefin_client.dart';
       7              : import 'package:app_finance/_classes/integration/simplefin/simplefin_importer.dart';
       8              : import 'package:app_finance/_classes/integration/simplefin/simplefin_settings.dart';
       9              : import 'package:app_finance/_classes/storage/app_data.dart';
      10              : import 'package:app_finance/_classes/storage/app_preferences.dart';
      11              : import 'package:app_finance/_classes/storage/transaction_log.dart';
      12              : import 'package:app_finance/_configs/theme_helper.dart';
      13              : import 'package:app_finance/_ext/build_context_ext.dart';
      14              : import 'package:app_finance/design/button/full_sized_button_widget.dart';
      15              : import 'package:app_finance/design/generic/loading_widget.dart';
      16              : import 'package:app_finance/design/wrapper/input_wrapper.dart';
      17              : import 'package:app_finance/pages/integration/widgets/integration_account_card.dart';
      18              : import 'package:material_ui/material_ui.dart';
      19              : import 'package:provider/provider.dart';
      20              : 
      21              : class SimpleFinBankAccount extends IntegrationTarget {
      22              :   final SimpleFinAccount account;
      23              : 
      24            0 :   SimpleFinBankAccount(this.account, {super.appAccount, super.category, required super.from});
      25              : 
      26            0 :   String get title => [account.institution ?? '', account.name].where((e) => e.isNotEmpty).join(' — ');
      27              : }
      28              : 
      29              : class SimpleFinWidget extends StatefulWidget {
      30              :   final SimpleFinClient? client;
      31              : 
      32            1 :   const SimpleFinWidget({super.key, this.client});
      33              : 
      34            0 :   @override
      35            0 :   SimpleFinWidgetState createState() => SimpleFinWidgetState();
      36              : }
      37              : 
      38              : class SimpleFinWidgetState extends State<SimpleFinWidget> {
      39              :   late final SimpleFinClient client;
      40              :   late final SimpleFinSettings settings;
      41              :   final token = TextEditingController();
      42              :   Uri? access;
      43              :   List<SimpleFinBankAccount> accounts = [];
      44              :   bool isLoading = false;
      45              :   String? error;
      46              :   String? notice;
      47              : 
      48            0 :   @override
      49              :   void initState() {
      50            0 :     super.initState();
      51            0 :     client = widget.client ?? SimpleFinClient();
      52            0 :     settings = SimpleFinSettings.load();
      53            0 :     access = SimpleFinSettings.loadAccess();
      54              :   }
      55              : 
      56            0 :   @override
      57              :   void dispose() {
      58            0 :     token.dispose();
      59            0 :     if (widget.client == null) {
      60            0 :       client.close();
      61              :     }
      62            0 :     super.dispose();
      63              :   }
      64              : 
      65            0 :   Future<void> _run(Future<void> Function() callback) async {
      66            0 :     setState(() {
      67            0 :       isLoading = true;
      68            0 :       error = null;
      69            0 :       notice = null;
      70              :     });
      71              :     try {
      72            0 :       await callback();
      73            0 :     } on SimpleFinException catch (e) {
      74            0 :       error = e.message;
      75              :     } catch (e) {
      76            0 :       error = e.toString();
      77              :     } finally {
      78            0 :       if (mounted) {
      79            0 :         setState(() => isLoading = false);
      80              :       }
      81              :     }
      82              :   }
      83              : 
      84            0 :   void _report(List<String> errors) {
      85            0 :     if (errors.isNotEmpty) {
      86            0 :       notice = [if (notice != null) notice!, ...errors].join('\n');
      87              :     }
      88              :   }
      89              : 
      90            0 :   Future<void> connect() => _run(() async {
      91            0 :         if (access == null) {
      92            0 :           final value = token.text.trim();
      93            0 :           if (value.isEmpty) {
      94            0 :             throw SimpleFinException(AppLocale.labels.simpleFinToken);
      95              :           }
      96            0 :           final claimed = await client.claim(value);
      97            0 :           await SimpleFinSettings.saveAccess(claimed);
      98            0 :           token.clear();
      99            0 :           access = claimed;
     100              :         }
     101            0 :         final result = await client.getAccounts(access!, balancesOnly: true);
     102            0 :         final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
     103            0 :         final monthAgo = DateTime.now().subtract(const Duration(days: 30));
     104            0 :         accounts = result.accounts
     105            0 :             .map((account) => SimpleFinBankAccount(
     106              :                   account,
     107            0 :                   appAccount: settings.links[account.id],
     108              :                   category: defaultBudget,
     109            0 :                   from: settings.imported[account.id] ?? monthAgo,
     110              :                 ))
     111            0 :             .toList();
     112            0 :         if (accounts.isEmpty) {
     113            0 :           notice = AppLocale.labels.integrationNoAccounts;
     114              :         }
     115            0 :         _report(result.errors);
     116              :       });
     117              : 
     118            0 :   Future<void> disconnect() => _run(() async => accounts = []);
     119              : 
     120            0 :   Future<void> forget() => _run(() async {
     121            0 :         await SimpleFinSettings.clearAccess();
     122            0 :         access = null;
     123            0 :         accounts = [];
     124              :       });
     125              : 
     126            0 :   Future<void> importTransactions(SimpleFinBankAccount item) => _run(() async {
     127            0 :         final current = access;
     128              :         if (current == null) {
     129              :           return;
     130              :         }
     131            0 :         if (item.appAccount == null || item.category == null) {
     132            0 :           throw SimpleFinException(AppLocale.labels.integrationSelectTarget);
     133              :         }
     134            0 :         final state = Provider.of<AppData>(context, listen: false);
     135            0 :         final result = await client.getAccounts(current, start: item.from, accountIds: [item.account.id]);
     136            0 :         final source = result.accounts.where((e) => e.id == item.account.id).firstOrNull;
     137            0 :         final importer = SimpleFinImporter(
     138            0 :           exists: (uuid) => state.getByUuid(uuid) != null,
     139            0 :           add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
     140              :         );
     141              :         final summary = source != null
     142            0 :             ? importer.run(source: source, account: item.appAccount!, category: item.category!)
     143              :             : (added: 0, skipped: 0, pending: 0);
     144            0 :         await state.restate();
     145            0 :         final today = DateTime.now();
     146            0 :         settings.links[item.account.id] = item.appAccount!;
     147            0 :         settings.imported[item.account.id] = DateTime(today.year, today.month, today.day);
     148            0 :         await settings.save();
     149            0 :         notice = AppLocale.labels.integrationImported(summary.added, summary.pending, summary.skipped);
     150            0 :         _report(result.errors);
     151              :       });
     152              : 
     153            0 :   Widget buildAccount(BuildContext context, SimpleFinBankAccount item, AppData state) {
     154            0 :     final account = item.account;
     155            0 :     return IntegrationAccountCard(
     156            0 :       title: item.title,
     157            0 :       subtitle: '${AppLocale.labels.balance}: ${account.balance} ${account.currency}',
     158              :       target: item,
     159              :       state: state,
     160            0 :       isDisabled: isLoading,
     161            0 :       onChanged: () => setState(() {}),
     162            0 :       onImport: () => importTransactions(item),
     163              :     );
     164              :   }
     165              : 
     166            0 :   @override
     167              :   Widget build(BuildContext context) {
     168            0 :     final textTheme = context.textTheme;
     169            0 :     final colorScheme = context.colorScheme;
     170            0 :     return Consumer<AppData>(builder: (context, state, _) {
     171            0 :       return Column(
     172            0 :         crossAxisAlignment: AppDesign.getAlignment(),
     173            0 :         children: [
     174            0 :           Text(AppLocale.labels.simpleFinDescription, style: textTheme.bodyMedium),
     175              :           ThemeHelper.hIndent2x,
     176            0 :           if (access == null)
     177            0 :             InputWrapper.text(
     178            0 :               title: AppLocale.labels.simpleFinToken,
     179            0 :               tooltip: AppLocale.labels.simpleFinTokenTooltip,
     180            0 :               controller: token,
     181              :               isRequired: true,
     182              :             ),
     183            0 :           ThemeHelper.hIndent2x,
     184            0 :           if (error != null) ...[
     185            0 :             Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
     186              :             ThemeHelper.hIndent,
     187              :           ],
     188            0 :           if (notice != null) ...[
     189            0 :             Text(notice!, style: textTheme.bodyLarge),
     190              :             ThemeHelper.hIndent,
     191              :           ],
     192            0 :           if (isLoading)
     193            0 :             const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
     194            0 :           else ...[
     195            0 :             accounts.isEmpty
     196            0 :                 ? FullSizedButtonWidget(
     197              :                     key: const ValueKey('simplefin_connect'),
     198              :                     icon: Icons.login,
     199            0 :                     onPressed: connect,
     200            0 :                     title: AppLocale.labels.integrationConnect,
     201              :                   )
     202            0 :                 : FullSizedButtonWidget(
     203              :                     key: const ValueKey('simplefin_disconnect'),
     204              :                     icon: Icons.logout,
     205            0 :                     onPressed: disconnect,
     206            0 :                     title: AppLocale.labels.integrationDisconnect,
     207              :                   ),
     208            0 :             if (access != null)
     209            0 :               Align(
     210              :                 alignment: Alignment.centerRight,
     211            0 :                 child: TextButton.icon(
     212              :                   key: const ValueKey('simplefin_forget'),
     213              :                   icon: const Icon(Icons.link_off),
     214            0 :                   onPressed: forget,
     215            0 :                   label: Text(AppLocale.labels.integrationForget),
     216              :                 ),
     217              :               ),
     218              :           ],
     219            0 :           for (final item in accounts) buildAccount(context, item, state),
     220              :         ],
     221              :       );
     222              :     });
     223              :   }
     224              : }
        

Generated by: LCOV version 2.0-1