/// [ChangeNotifier] for bank link status, balance, and transactions. library; import 'package:flutter/foundation.dart'; import '../models/bank.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. class BankController extends ChangeNotifier { BankController({IngtrakApi? api, this._storage}) : _api = api ?? IngtrakApi() { final cached = _storage?.loadBankCache(); if (cached != null && cached.status.linked) { _status = cached.status; _balance = cached.balance; _transactions = cached.transactions; } } final IngtrakApi _api; final StorageService? _storage; ConnectStatus _status = ConnectStatus.disconnected; BankBalance? _balance; List _transactions = const []; bool _isLoading = false; bool _isConnecting = false; String? _error; String? _pendingState; ConnectStatus get status => _status; BankBalance? get balance => _balance; List get transactions => List.unmodifiable(_transactions); bool get isLoading => _isLoading; bool get isConnecting => _isConnecting; String? get error => _error; bool get isLinked => _status.linked; String get apiBaseUrl => _api.baseUrl; String? get pendingState => _pendingState; Future _persistCache() async { final storage = _storage; if (storage == null) return; if (!_status.linked) { await storage.clearBankCache(); return; } await storage.saveBankCache( BankCache( status: _status, balance: _balance, transactions: _transactions, ), ); } /// Load status; if linked, also load balance + transactions. Future refresh() async { _isLoading = true; _error = null; notifyListeners(); try { _status = await _api.fetchStatus(); if (_status.linked) { await _loadLinkedData(); await _persistCache(); } else { _balance = null; _transactions = const []; await _persistCache(); } } on IngtrakApiException catch (error, stackTrace) { // Keep cached linked data on transient API errors. if (!isLinked || (_balance == null && _transactions.isEmpty)) { _error = error.message; } else { _error = 'Using saved data · ${error.message}'; } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } catch (error, stackTrace) { if (!isLinked || (_balance == null && _transactions.isEmpty)) { _error = 'Could not reach $apiBaseUrl'; } else { _error = 'Using saved data · offline/unreachable API'; } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } finally { _isLoading = false; notifyListeners(); } } Future _loadLinkedData() async { try { final results = await Future.wait([ _api.fetchBalance(), _api.fetchTransactions(), ]); _balance = results[0] as BankBalance; _transactions = results[1] as List; _error = null; } on IngtrakApiException catch (error) { if (error.isNotLinked) { _status = ConnectStatus.disconnected; _balance = null; _transactions = const []; _error = error.message; await _persistCache(); } else if (_balance == null && _transactions.isEmpty) { _error = error.message; } else { // Keep previous balance/txs if refresh of data fails but still linked. _error = 'Using saved data · ${error.message}'; } } } /// `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); } // Backend already exchanged the code; just refresh local state. if (uri.queryParameters['linked'] == '1') { await refresh(); if (!isLinked) { _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; } 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; if (_status.linked) { await _loadLinkedData(); await _persistCache(); } return _status.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 { _isLoading = false; notifyListeners(); } } @override void dispose() { _api.close(); super.dispose(); } }