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 : }
|