/// [ChangeNotifier] for bank link status, balance, and transactions. library; import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/bank.dart'; import '../services/ing_csv_import.dart'; import '../services/ingtrak_api.dart'; import '../services/storage_service.dart'; import '../utils/constants.dart'; /// App-wide banking state backed by the local ingtrak API + optional CSV import. class BankController extends ChangeNotifier { BankController({IngtrakApi? api, this._storage}) : _api = api ?? IngtrakApi() { final cached = _storage?.loadBankCache(); if (cached != null) { // Restore last snapshot even if status.linked was lost — avoid empty UI. _status = cached.status.linked ? cached.status : ConnectStatus( status: cached.status.status, linked: cached.balance != null || cached.transactions.isNotEmpty, accountId: cached.status.accountId, sessionId: cached.status.sessionId, state: cached.status.state, validUntil: cached.status.validUntil, ); _balance = cached.balance; _remoteTransactions = cached.transactions; } _importedTransactions = _storage?.loadImportedTransactions() ?? const []; _importedTransactions = [ for (final tx in _importedTransactions) _upgradeLegacyManual(tx), ]; _remoteTransactions = [ for (final tx in _remoteTransactions) _upgradeLegacyManual(tx), ]; _pendingRemoteDeletes = _storage?.loadPendingTxDeletes() ?? const []; // Drop anything still queued for remote delete from the local caches. if (_pendingRemoteDeletes.isNotEmpty) { _remoteTransactions = [ for (final tx in _remoteTransactions) if (!_isTombstoned(tx)) tx, ]; _importedTransactions = [ for (final tx in _importedTransactions) if (!_isTombstoned(tx)) tx, ]; } _invalidateMerged(); } /// Older admin test rows used a ·#stamp suffix before [isManual] existed. static BankTransaction _upgradeLegacyManual(BankTransaction tx) { if (tx.isManual) return tx; if (!RegExp(r' ·#\d+$').hasMatch(tx.description)) return tx; return tx.copyWith(isManual: true); } final IngtrakApi _api; final StorageService? _storage; /// Fired after a successful live balance + transactions fetch. /// /// Used by [SyncController] to reconcile with PocketBase so a bank refresh /// cannot leave the device behind the cloud (or wipe cloud-only history). VoidCallback? onAfterSuccessfulLoad; /// Fired when local admin txs are removed/replaced and need a remote delete. VoidCallback? onPendingRemoteDeletesChanged; ConnectStatus _status = ConnectStatus.disconnected; BankBalance? _balance; List _remoteTransactions = const []; List _importedTransactions = const []; List _pendingRemoteDeletes = const []; /// Tombstoned txs waiting to be removed from PocketBase (also persisted). List get pendingRemoteDeletes => List.unmodifiable(_pendingRemoteDeletes); bool get hasPendingRemoteDeletes => _pendingRemoteDeletes.isNotEmpty; bool _isTombstoned(BankTransaction tx) { for (final tomb in _pendingRemoteDeletes) { if (tx.matchesForDelete(tomb)) return true; } return false; } /// Drop tombstones that match any of [txs] (fingerprint-aware). void clearRemoteDeletesMatching(Iterable txs) { final list = txs.toList(growable: false); if (list.isEmpty) return; final next = [ for (final tomb in _pendingRemoteDeletes) if (!list.any(tomb.matchesForDelete)) tomb, ]; if (next.length == _pendingRemoteDeletes.length) return; _pendingRemoteDeletes = next; unawaited(_persistPendingDeletes()); } void clearAllPendingRemoteDeletes() { if (_pendingRemoteDeletes.isEmpty) return; _pendingRemoteDeletes = const []; unawaited(_persistPendingDeletes()); } void requeueRemoteDeletes(Iterable txs) { for (final tx in txs) { _queueRemoteDelete(tx, notify: false); } unawaited(_persistPendingDeletes()); } void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) { if (tx.id.isEmpty) return; if (_isTombstoned(tx)) return; _pendingRemoteDeletes = [..._pendingRemoteDeletes, tx]; unawaited(_persistPendingDeletes()); if (notify) onPendingRemoteDeletesChanged?.call(); } Future _persistPendingDeletes() async { final storage = _storage; if (storage == null) return; await storage.savePendingTxDeletes(_pendingRemoteDeletes); } /// Keep [isManual] when a synced/API row replaces a matching imported test tx. void _reapplyManualFlags(Set manualIds) { if (manualIds.isEmpty) return; BankTransaction stamp(BankTransaction tx) => manualIds.contains(tx.id) && !tx.isManual ? tx.copyWith(isManual: true) : tx; _remoteTransactions = [for (final tx in _remoteTransactions) stamp(tx)]; _importedTransactions = [ for (final tx in _importedTransactions) stamp(tx), ]; } Set _collectManualIds([ Iterable extra = const [], ]) => { for (final tx in [ ..._remoteTransactions, ..._importedTransactions, ...extra, ]) if (tx.isManual) tx.id, }; List? _mergedTransactions; bool _isLoading = false; bool _isConnecting = false; bool _isImporting = false; String? _error; String? _pendingState; ConnectStatus get status => _status; BankBalance? get balance => _balance; /// Remote API txs merged with locally imported CSV txs (deduped). /// /// Cached — [mergeTransactions] is O(n·m) and was re-running on every /// `bank.transactions` read during rebuilds. List get transactions => _mergedTransactions ??= mergeTransactions(_remoteTransactions, _importedTransactions); void _invalidateMerged() { _mergedTransactions = null; } int get importedCount => _importedTransactions.length; bool get isLoading => _isLoading; bool get isConnecting => _isConnecting; bool get isImporting => _isImporting; String? get error => _error; bool get isLinked => _status.linked; /// True when we have anything useful to show (API cache and/or CSV import). bool get hasData => _balance != null || _remoteTransactions.isNotEmpty || _importedTransactions.isNotEmpty; String get apiBaseUrl => _api.baseUrl; String? get pendingState => _pendingState; Future _persistCache() async { final storage = _storage; if (storage == null) return; if (_balance == null && _remoteTransactions.isEmpty) { // Only clear disk when we truly have nothing left to show from the API. if (!_status.linked) { await storage.clearBankCache(); } return; } await storage.saveBankCache( BankCache( status: ConnectStatus( status: _status.status, linked: true, accountId: _status.accountId, sessionId: _status.sessionId, state: _status.state, validUntil: _status.validUntil, ), balance: _balance, transactions: _remoteTransactions, ), ); } Future _persistImported() async { final storage = _storage; if (storage == null) return; await storage.saveImportedTransactions(_importedTransactions); } /// Import an ING CSV/semicolon export. Skips rows that match existing txs. Future importIngCsv(String raw) async { _isImporting = true; _error = null; notifyListeners(); try { final result = importIngCsvAgainst( raw: raw, existing: transactions, ); if (result.added.isNotEmpty) { _importedTransactions = mergeTransactions( _importedTransactions, result.added, ); _importedTransactions = filterNewTransactions( _remoteTransactions, _importedTransactions, ); _invalidateMerged(); await _persistImported(); } return result; } on FormatException catch (error) { _error = error.message; rethrow; } catch (error, stackTrace) { _error = 'Could not import CSV'; debugPrint('BankController.importIngCsv failed: $error\n$stackTrace'); rethrow; } finally { _isImporting = false; notifyListeners(); } } Future clearImportedTransactions() async { _importedTransactions = const []; _invalidateMerged(); await _storage?.clearImportedTransactions(); notifyListeners(); } /// Insert a locally created transaction (admin testing). Persisted like CSV /// imports and included in PocketBase `transactions` sync. Future addManualTransaction({ required DateTime date, required String description, required double amount, String currency = 'EUR', bool pending = false, }) async { final desc = description.trim().isEmpty ? 'Test transaction' : description.trim(); // Include a short unique suffix so identical drafts do not collide on id. final stamp = DateTime.now().millisecondsSinceEpoch % 100000; final tx = BankTransaction( date: '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' '${date.day.toString().padLeft(2, '0')}', description: '$desc ·#$stamp', amount: amount, currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(), pending: pending, isManual: true, ); _importedTransactions = mergeTransactions(_importedTransactions, [tx]); _importedTransactions = filterNewTransactions( _remoteTransactions, _importedTransactions, ); _invalidateMerged(); await _persistImported(); notifyListeners(); return tx; } /// Replace an admin test transaction. Returns the updated row (id may change). Future updateManualTransaction({ required BankTransaction previous, required DateTime date, required String description, required double amount, String currency = 'EUR', bool pending = false, }) async { if (!previous.isManual) { throw StateError('Only admin test transactions can be edited'); } final desc = description.trim().isEmpty ? 'Test transaction' : description.trim(); final stamped = () { if (RegExp(r' ·#\d+$').hasMatch(desc)) return desc; final prevStamp = RegExp(r' ·#(\d+)$').firstMatch(previous.description); final prevBase = previous.description.replaceFirst(RegExp(r' ·#\d+$'), ''); if (prevStamp != null && prevBase == desc) { return '$desc ·#${prevStamp.group(1)}'; } return '$desc ·#${DateTime.now().millisecondsSinceEpoch % 100000}'; }(); final updated = BankTransaction( date: '${date.year.toString().padLeft(4, '0')}-' '${date.month.toString().padLeft(2, '0')}-' '${date.day.toString().padLeft(2, '0')}', description: stamped, amount: amount, currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(), pending: pending, isManual: true, ); _importedTransactions = [ for (final tx in _importedTransactions) if (tx.id != previous.id) tx, ]; _remoteTransactions = [ for (final tx in _remoteTransactions) if (tx.id != previous.id) tx, ]; if (previous.id != updated.id) { _queueRemoteDelete(previous); } _importedTransactions = mergeTransactions(_importedTransactions, [updated]); _invalidateMerged(); await _persistImported(); await _persistCache(); notifyListeners(); return updated; } /// Remove an admin test transaction from local caches. Future deleteManualTransaction(BankTransaction tx) async { if (!tx.isManual) { throw StateError('Only admin test transactions can be deleted'); } _importedTransactions = [ for (final row in _importedTransactions) if (!row.matchesForDelete(tx)) row, ]; _remoteTransactions = [ for (final row in _remoteTransactions) if (!row.matchesForDelete(tx)) row, ]; _queueRemoteDelete(tx); _invalidateMerged(); await _persistImported(); await _persistCache(); notifyListeners(); } /// Union cloud txs into the local bank cache without dropping either side. /// /// Rows land in the API cache; CSV imports that match are deduped away. Future applySyncedTransactions( List fromCloud, { bool notify = true, }) async { if (fromCloud.isEmpty && _pendingRemoteDeletes.isEmpty) return; // Never resurrect rows the user already deleted locally. final incoming = [ for (final tx in fromCloud) if (!_isTombstoned(tx)) _upgradeLegacyManual(tx), ]; if (incoming.isEmpty && _pendingRemoteDeletes.isEmpty) return; final manuals = _collectManualIds(incoming); if (incoming.isNotEmpty) { _remoteTransactions = mergeTransactions(_remoteTransactions, incoming); } // Strip any local copies that match a pending delete (defensive). if (_pendingRemoteDeletes.isNotEmpty) { _remoteTransactions = [ for (final tx in _remoteTransactions) if (!_isTombstoned(tx)) tx, ]; } _importedTransactions = filterNewTransactions( _remoteTransactions, _importedTransactions, ); if (_pendingRemoteDeletes.isNotEmpty) { _importedTransactions = [ for (final tx in _importedTransactions) if (!_isTombstoned(tx)) tx, ]; } _reapplyManualFlags(manuals); _invalidateMerged(); if (!_status.linked && _remoteTransactions.isNotEmpty) { _status = ConnectStatus( status: _status.status == 'disconnected' ? 'synced' : _status.status, linked: true, accountId: _status.accountId, sessionId: _status.sessionId, state: _status.state, validUntil: _status.validUntil, ); } await _persistCache(); await _persistImported(); if (notify) notifyListeners(); } /// Load status; if linked, also load balance + transactions. Future refresh() async { _isLoading = true; _error = null; notifyListeners(); var loadedLive = false; try { try { _status = await _api.fetchStatus(); } on IngtrakApiException catch (error) { // Status often 500 while balance/tx still work — keep going if we // already have a link (or local data). if (!isLinked && !hasData) rethrow; debugPrint( 'BankController.fetchStatus soft-failed (using cache): $error', ); } // Prefer loading live data whenever we believe we are linked OR still // have a local snapshot. Never wipe local txs because status said // "unlinked" — only a 409 from balance/transactions clears them. if (_status.linked || hasData) { loadedLive = await _loadLinkedData(); await _persistCache(); } } on IngtrakApiException catch (error, stackTrace) { if (!hasData) { _error = error.message; } else { _error = 'Using saved data · ${error.message}'; } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } catch (error, stackTrace) { if (!hasData) { _error = 'Could not reach $apiBaseUrl'; } else { _error = 'Using saved data · offline/unreachable API'; } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } finally { _isLoading = false; notifyListeners(); } if (loadedLive) { onAfterSuccessfulLoad?.call(); } } /// Returns true when balance + transactions were fetched successfully. Future _loadLinkedData() async { try { final results = await Future.wait([ _api.fetchBalance(), _api.fetchTransactions(), ]); _balance = results[0] as BankBalance; final apiTxs = results[1] as List; // Bank API is ~89 days. Prefer API rows for overlaps, but keep any // cloud/CSV history that the API window does not include. final manuals = _collectManualIds(); _remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions); if (!_status.linked) { _status = ConnectStatus( status: 'linked', linked: true, accountId: _status.accountId, sessionId: _status.sessionId, state: _status.state, validUntil: _status.validUntil, ); } _importedTransactions = filterNewTransactions( _remoteTransactions, _importedTransactions, ); if (_pendingRemoteDeletes.isNotEmpty) { _remoteTransactions = [ for (final tx in _remoteTransactions) if (!_isTombstoned(tx)) tx, ]; _importedTransactions = [ for (final tx in _importedTransactions) if (!_isTombstoned(tx)) tx, ]; } _reapplyManualFlags(manuals); _invalidateMerged(); await _persistImported(); _error = null; return true; } on IngtrakApiException catch (error) { if (error.isNotLinked) { // Confirmed unlinked by data endpoints — clear remote only. _status = ConnectStatus.disconnected; _balance = null; _remoteTransactions = const []; _invalidateMerged(); _error = error.message; await _persistCache(); } else if (!hasData) { _error = error.message; } else { // Keep previous balance/txs if refresh of data fails. _error = 'Using saved data · ${error.message}'; } return false; } } /// `POST /api/connect`. Returns auth payload for the in-app WebView. Future beginConnect() async { _isConnecting = true; _error = null; notifyListeners(); try { final start = await _api.startConnect(); if (start.authUrl.isEmpty) { throw const IngtrakApiException('Connect response missing authUrl'); } _pendingState = start.state; return start; } on IngtrakApiException catch (error, stackTrace) { _error = error.message; debugPrint('BankController.beginConnect failed: $error\n$stackTrace'); return null; } catch (error, stackTrace) { _error = 'Could not start bank connect.'; debugPrint('BankController.beginConnect failed: $error\n$stackTrace'); return null; } finally { _isConnecting = false; notifyListeners(); } } /// Handle optional `ingtrak://connected?...` (e.g. after HTML bounce). Future handleRedirect(Uri uri) async { if (uri.scheme != kBankRedirectScheme) return false; if (uri.host != kBankRedirectHost && uri.path != '/$kBankRedirectHost') { if (uri.host.isNotEmpty && uri.host != kBankRedirectHost) return false; } final code = uri.queryParameters['code']; if (code != null && code.isNotEmpty) { final state = uri.queryParameters['state'] ?? _pendingState; return completeWithCode(code: code, state: state); } if (uri.queryParameters['linked'] == '1') { await refresh(); if (!isLinked && !hasData) { _error = 'Returned to the app, but the backend still has no linked session. ' 'Finish login inside the in-app WebView (do not open the email ' 'link in another browser), then pull to refresh.'; notifyListeners(); } return isLinked || hasData; } final redirectError = uri.queryParameters['error']; _error = redirectError != null ? 'Bank auth failed: $redirectError' : 'Bank redirect missing code'; notifyListeners(); return true; } Future completeWithCode({ required String code, String? state, }) async { _isLoading = true; _error = null; notifyListeners(); try { _status = await _api.completeConnect( code: code, state: state ?? _pendingState, ); _pendingState = null; var loadedLive = false; if (_status.linked) { loadedLive = await _loadLinkedData(); await _persistCache(); } final linked = _status.linked; // Notify before cloud reconcile so UI can leave the connecting state. _isLoading = false; notifyListeners(); if (loadedLive) { onAfterSuccessfulLoad?.call(); } return linked; } on IngtrakApiException catch (error, stackTrace) { _error = error.message; debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); return false; } catch (error, stackTrace) { _error = 'Could not complete bank connect.'; debugPrint('BankController.completeWithCode failed: $error\n$stackTrace'); return false; } finally { if (_isLoading) { _isLoading = false; notifyListeners(); } } } @override void dispose() { onAfterSuccessfulLoad = null; _api.close(); super.dispose(); } }