689 lines
22 KiB
Dart
689 lines
22 KiB
Dart
/// [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<BankTransaction> _remoteTransactions = const [];
|
|
List<BankTransaction> _importedTransactions = const [];
|
|
List<BankTransaction> _pendingRemoteDeletes = const [];
|
|
|
|
/// Tombstoned txs waiting to be removed from PocketBase (also persisted).
|
|
List<BankTransaction> get pendingRemoteDeletes =>
|
|
List<BankTransaction>.unmodifiable(_pendingRemoteDeletes);
|
|
|
|
bool get hasPendingRemoteDeletes => _pendingRemoteDeletes.isNotEmpty;
|
|
|
|
bool _isTombstoned(BankTransaction tx) {
|
|
for (final tomb in _pendingRemoteDeletes) {
|
|
if (tx.matchesForDelete(tomb)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void requeueRemoteDeletes(Iterable<BankTransaction> 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.copyWith(isManual: true),
|
|
];
|
|
unawaited(_persistPendingDeletes());
|
|
if (notify) onPendingRemoteDeletesChanged?.call();
|
|
}
|
|
|
|
/// Merge delete tombstones from another device and strip matching local rows.
|
|
Future<void> applyRemoteDeletes(
|
|
List<BankTransaction> remote, {
|
|
bool notify = true,
|
|
}) async {
|
|
var tombsChanged = false;
|
|
for (final tomb in remote) {
|
|
if (_isTombstoned(tomb)) continue;
|
|
_pendingRemoteDeletes = [
|
|
..._pendingRemoteDeletes,
|
|
tomb.copyWith(isManual: true),
|
|
];
|
|
tombsChanged = true;
|
|
}
|
|
if (_pendingRemoteDeletes.isEmpty) return;
|
|
|
|
final beforeRemote = _remoteTransactions.length;
|
|
final beforeImported = _importedTransactions.length;
|
|
_remoteTransactions = [
|
|
for (final tx in _remoteTransactions)
|
|
if (!_isTombstoned(tx)) tx,
|
|
];
|
|
_importedTransactions = [
|
|
for (final tx in _importedTransactions)
|
|
if (!_isTombstoned(tx)) tx,
|
|
];
|
|
final stripped = beforeRemote != _remoteTransactions.length ||
|
|
beforeImported != _importedTransactions.length;
|
|
|
|
if (tombsChanged || stripped) {
|
|
_invalidateMerged();
|
|
if (tombsChanged) await _persistPendingDeletes();
|
|
if (stripped) {
|
|
await _persistCache();
|
|
await _persistImported();
|
|
}
|
|
if (notify) notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _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<String> 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<String> _collectManualIds([
|
|
Iterable<BankTransaction> extra = const [],
|
|
]) =>
|
|
{
|
|
for (final tx in [
|
|
..._remoteTransactions,
|
|
..._importedTransactions,
|
|
...extra,
|
|
])
|
|
if (tx.isManual) tx.id,
|
|
};
|
|
List<BankTransaction>? _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<BankTransaction> 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<void> _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<void> _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<CsvImportResult> 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<void> 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<BankTransaction> 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<BankTransaction> 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<void> 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<void> applySyncedTransactions(
|
|
List<BankTransaction> 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 = dropSettledPendingDuplicates(
|
|
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<void> 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<bool> _loadLinkedData() async {
|
|
try {
|
|
final results = await Future.wait([
|
|
_api.fetchBalance(),
|
|
_api.fetchTransactions(),
|
|
]);
|
|
_balance = results[0] as BankBalance;
|
|
final apiTxs = results[1] as List<BankTransaction>;
|
|
// Bank API is ~89 days. Prefer API rows for overlaps, but keep any
|
|
// cloud/CSV history that the API window does not include.
|
|
// Pending rows are not kept from cache — they must still be on the feed.
|
|
final manuals = _collectManualIds();
|
|
_remoteTransactions =
|
|
mergeApiTransactionsWithCache(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<ConnectStart?> 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<bool> 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<bool> 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();
|
|
}
|
|
}
|