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