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 0 : EnableBankingBankAccount(this.account, {super.appAccount, super.category, required super.from});
33 :
34 0 : String get title {
35 0 : final number = account.iban ?? '';
36 0 : final masked = number.length > 4 ? '…${number.substring(number.length - 4)}' : number;
37 0 : 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 1 : const EnableBankingWidget({super.key, this.httpClient, this.launcher});
46 :
47 0 : @override
48 0 : 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 0 : @override
67 : void initState() {
68 0 : super.initState();
69 0 : settings = EnableBankingSettings.load();
70 0 : session = EnableBankingSettings.loadSession();
71 0 : authorizationState = settings.authorizationState.isEmpty ? null : settings.authorizationState;
72 0 : applicationId = TextEditingController(text: settings.applicationId);
73 0 : redirectUrl = TextEditingController(
74 0 : text: settings.redirectUrl.isEmpty ? EnableBankingSettings.defaultRedirectUrl : settings.redirectUrl,
75 : );
76 0 : country = TextEditingController(text: settings.country);
77 0 : DeepLinkHandler.enableBankingCallback.addListener(onLink);
78 : // A callback that came before this section was open (e.g. the application was restarted meanwhile)
79 0 : WidgetsBinding.instance.addPostFrameCallback((_) => onLink());
80 : }
81 :
82 0 : @override
83 : void dispose() {
84 0 : DeepLinkHandler.enableBankingCallback.removeListener(onLink);
85 0 : for (final controller in [applicationId, redirectUrl, country, privateKey, code]) {
86 0 : controller.dispose();
87 : }
88 0 : super.dispose();
89 : }
90 :
91 0 : Future<void> _run(Future<void> Function(EnableBankingClient client) callback) async {
92 0 : setState(() {
93 0 : isLoading = true;
94 0 : error = null;
95 0 : notice = null;
96 : });
97 : EnableBankingClient? client;
98 : try {
99 0 : await saveSettings();
100 0 : final key = EnableBankingSettings.loadKey();
101 0 : if (settings.applicationId.isEmpty || key == null || settings.redirectUrl.isEmpty || settings.country.isEmpty) {
102 0 : throw EnableBankingException(AppLocale.labels.enableBankingIncomplete);
103 : }
104 0 : client = EnableBankingClient(
105 0 : applicationId: settings.applicationId,
106 : privateKey: key,
107 0 : client: widget.httpClient ?? http.Client(),
108 : );
109 0 : await callback(client);
110 0 : } on EnableBankingException catch (e) {
111 0 : error = e.message;
112 : } catch (e) {
113 0 : error = e.toString();
114 : } finally {
115 0 : if (widget.httpClient == null) {
116 0 : client?.close();
117 : }
118 0 : if (mounted) {
119 0 : setState(() => isLoading = false);
120 : }
121 : }
122 : }
123 :
124 0 : Future<void> saveSettings() async {
125 0 : final key = privateKey.text.trim();
126 0 : if (key.isNotEmpty) {
127 0 : EnableBankingClient.parsePrivateKey(key);
128 0 : await EnableBankingSettings.saveKey(EnableBankingClient.normalizePrivateKey(key));
129 0 : privateKey.clear();
130 : }
131 0 : settings
132 0 : ..applicationId = applicationId.text.trim()
133 0 : ..redirectUrl = redirectUrl.text.trim()
134 0 : ..country = country.text.trim().toUpperCase();
135 0 : await settings.save();
136 : }
137 :
138 0 : void onLink() {
139 0 : if (!mounted || DeepLinkHandler.enableBankingCallback.value == null || authorizationState == null) {
140 : return;
141 : }
142 0 : if (isLoading) {
143 0 : Future.delayed(const Duration(milliseconds: 300), onLink);
144 : return;
145 : }
146 0 : confirm(link: DeepLinkHandler.takeEnableBankingCallback().toString());
147 : }
148 :
149 0 : Future<void> _setAuthorizationState(String? state) async {
150 0 : authorizationState = state;
151 0 : settings.authorizationState = state ?? '';
152 0 : await settings.save();
153 : }
154 :
155 0 : EnableBankingAspsp? get _aspsp {
156 0 : final selected = aspsps.where((e) => e.name == settings.aspsp).firstOrNull;
157 0 : if (selected != null || settings.aspsp.isEmpty) {
158 : return selected;
159 : }
160 0 : return EnableBankingAspsp(name: settings.aspsp, country: settings.country);
161 : }
162 :
163 0 : Future<void> loadBanks() => _run((client) async {
164 0 : aspsps = await client.getAspsps(settings.country);
165 0 : if (aspsps.isEmpty) {
166 0 : notice = AppLocale.labels.integrationNoAccounts;
167 : }
168 : });
169 :
170 0 : Future<void> connect() => _run((client) async {
171 0 : final current = session;
172 0 : if (current != null && current.isValid) {
173 0 : return _loadAccounts(client, current);
174 : }
175 0 : final aspsp = _aspsp;
176 : if (aspsp == null) {
177 0 : throw EnableBankingException(AppLocale.labels.enableBankingBank);
178 : }
179 0 : final state = const Uuid().v4();
180 0 : final url = await client.startAuthorization(aspsp: aspsp, redirectUrl: settings.redirectUrl, state: state);
181 0 : await _setAuthorizationState(state);
182 0 : 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 0 : Future<void> confirm({String? link}) => _run((client) async {
187 0 : final value = EnableBankingClient.extractCode(
188 0 : link ?? code.text,
189 0 : state: authorizationState,
190 : requireState: link != null,
191 : );
192 0 : final created = await client.createSession(value);
193 0 : await EnableBankingSettings.saveSession(created);
194 0 : session = created;
195 0 : await _setAuthorizationState(null);
196 0 : code.clear();
197 0 : await _loadAccounts(client, created);
198 : });
199 :
200 0 : Future<void> _loadAccounts(EnableBankingClient client, EnableBankingSession current) async {
201 0 : final defaultBudget = AppPreferences.get(AppPreferences.prefBudget);
202 0 : final monthAgo = DateTime.now().subtract(const Duration(days: 30));
203 0 : accounts = current.accounts
204 0 : .map((account) => EnableBankingBankAccount(
205 : account,
206 0 : appAccount: settings.links[account.key],
207 : category: defaultBudget,
208 0 : from: settings.imported[account.key] ?? monthAgo,
209 : ))
210 0 : .toList();
211 0 : for (final item in accounts) {
212 : try {
213 0 : item.balance = (await client.getBalances(item.account.uid)).firstOrNull;
214 0 : } on EnableBankingException {
215 : // Some banks offer no balances
216 : }
217 : }
218 0 : if (accounts.isEmpty) {
219 0 : notice = AppLocale.labels.integrationNoAccounts;
220 : }
221 : }
222 :
223 0 : Future<void> disconnect() async => setState(() => accounts = []);
224 :
225 0 : Future<void> forget() => _run((client) async {
226 0 : final current = session;
227 0 : session = null;
228 0 : accounts = [];
229 0 : await EnableBankingSettings.clearSession();
230 : if (current != null) {
231 0 : await client.deleteSession(current.id);
232 : }
233 : });
234 :
235 0 : Future<void> importTransactions(EnableBankingBankAccount item) => _run((client) async {
236 0 : if (item.appAccount == null || item.category == null) {
237 0 : throw EnableBankingException(AppLocale.labels.integrationSelectTarget);
238 : }
239 0 : final state = Provider.of<AppData>(context, listen: false);
240 0 : final transactions = await client.getTransactions(item.account.uid, from: item.from);
241 0 : final importer = EnableBankingImporter(
242 0 : exists: (uuid) => state.getByUuid(uuid) != null,
243 0 : add: (value, uuid) => TransactionLog.save(state.add(value, uuid)),
244 : );
245 0 : final summary = importer.run(
246 0 : accountKey: item.account.key,
247 : transactions: transactions,
248 0 : account: item.appAccount!,
249 0 : category: item.category!,
250 : );
251 0 : await state.restate();
252 0 : final today = DateTime.now();
253 0 : settings.links[item.account.key] = item.appAccount!;
254 0 : settings.imported[item.account.key] = DateTime(today.year, today.month, today.day);
255 0 : await settings.save();
256 0 : notice = AppLocale.labels.integrationImported(summary.added, summary.pending, summary.skipped);
257 : });
258 :
259 0 : Widget buildSettings(BuildContext context) {
260 0 : final banks = aspsps.isNotEmpty
261 0 : ? aspsps
262 0 : : [if (settings.aspsp.isNotEmpty) EnableBankingAspsp(name: settings.aspsp, country: settings.country)];
263 0 : return Column(
264 0 : crossAxisAlignment: AppDesign.getAlignment(),
265 0 : children: [
266 0 : InputWrapper.text(
267 0 : title: AppLocale.labels.enableBankingAppId,
268 0 : controller: applicationId,
269 : isRequired: true,
270 : ),
271 0 : InputWrapper.text(
272 0 : title: AppLocale.labels.enableBankingKey,
273 0 : tooltip: AppLocale.labels.enableBankingKeyTooltip,
274 0 : controller: privateKey,
275 0 : isRequired: EnableBankingSettings.loadKey() == null,
276 : ),
277 0 : InputWrapper.text(
278 0 : title: AppLocale.labels.enableBankingRedirect,
279 0 : tooltip: AppLocale.labels.enableBankingRedirectTooltip,
280 0 : controller: redirectUrl,
281 : isRequired: true,
282 : inputType: TextInputType.url,
283 : ),
284 0 : InputWrapper.text(
285 0 : title: AppLocale.labels.enableBankingCountry,
286 0 : controller: country,
287 : isRequired: true,
288 : ),
289 0 : if (banks.isNotEmpty)
290 0 : InputWrapper.select(
291 0 : title: AppLocale.labels.enableBankingBank,
292 0 : value: settings.aspsp.isEmpty ? null : settings.aspsp,
293 : isRequired: true,
294 0 : options: <ListSelectorItem>[for (final bank in banks) ListSelectorItem(id: bank.name, name: bank.name)],
295 0 : onChange: (value) => setState(() => settings.aspsp = value ?? ''),
296 : ),
297 0 : Align(
298 : alignment: Alignment.centerRight,
299 0 : child: TextButton.icon(
300 : key: const ValueKey('enablebanking_banks'),
301 : icon: const Icon(Icons.account_balance),
302 0 : onPressed: isLoading ? null : loadBanks,
303 0 : label: Text(AppLocale.labels.enableBankingLoadBanks),
304 : ),
305 : ),
306 : ],
307 : );
308 : }
309 :
310 0 : Widget buildAccount(BuildContext context, EnableBankingBankAccount item, AppData state) {
311 0 : final balance = item.balance;
312 0 : return IntegrationAccountCard(
313 0 : title: item.title,
314 0 : subtitle: balance != null ? '${AppLocale.labels.balance}: ${balance.amount} ${balance.currency}' : null,
315 : target: item,
316 : state: state,
317 0 : isDisabled: isLoading,
318 0 : onChanged: () => setState(() {}),
319 0 : onImport: () => importTransactions(item),
320 : );
321 : }
322 :
323 0 : @override
324 : Widget build(BuildContext context) {
325 0 : final textTheme = context.textTheme;
326 0 : final colorScheme = context.colorScheme;
327 0 : return Consumer<AppData>(builder: (context, state, _) {
328 0 : return Column(
329 0 : crossAxisAlignment: AppDesign.getAlignment(),
330 0 : children: [
331 0 : Text(AppLocale.labels.enableBankingDescription, style: textTheme.bodyMedium),
332 : ThemeHelper.hIndent2x,
333 0 : if (session == null) buildSettings(context),
334 0 : if (authorizationState != null)
335 0 : InputWrapper.text(
336 0 : title: AppLocale.labels.enableBankingCode,
337 0 : tooltip: AppLocale.labels.enableBankingCodeTooltip,
338 0 : controller: code,
339 : isRequired: true,
340 : inputType: TextInputType.url,
341 : ),
342 0 : ThemeHelper.hIndent2x,
343 0 : if (error != null) ...[
344 0 : Text(error!, style: textTheme.bodyLarge?.copyWith(color: colorScheme.error)),
345 : ThemeHelper.hIndent,
346 : ],
347 0 : if (notice != null) ...[
348 0 : Text(notice!, style: textTheme.bodyLarge),
349 : ThemeHelper.hIndent,
350 : ],
351 0 : if (isLoading)
352 0 : const Center(child: LoadingWidget(isLoading: true, size: Size(48, 48)))
353 0 : else ...[
354 0 : if (accounts.isNotEmpty)
355 0 : FullSizedButtonWidget(
356 : key: const ValueKey('enablebanking_disconnect'),
357 : icon: Icons.logout,
358 0 : onPressed: disconnect,
359 0 : title: AppLocale.labels.integrationDisconnect,
360 : )
361 : else
362 0 : FullSizedButtonWidget(
363 : key: const ValueKey('enablebanking_connect'),
364 : icon: Icons.login,
365 0 : onPressed: authorizationState != null ? confirm : connect,
366 0 : title: AppLocale.labels.integrationConnect,
367 : ),
368 0 : if (session != null)
369 0 : Align(
370 : alignment: Alignment.centerRight,
371 0 : child: TextButton.icon(
372 : key: const ValueKey('enablebanking_forget'),
373 : icon: const Icon(Icons.link_off),
374 0 : onPressed: forget,
375 0 : label: Text(AppLocale.labels.integrationForget),
376 : ),
377 : ),
378 : ],
379 0 : for (final item in accounts) buildAccount(context, item, state),
380 : ],
381 : );
382 : });
383 : }
384 : }
|