ing csv import
This commit is contained in:
@@ -4,20 +4,32 @@ library;
|
||||
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.
|
||||
/// 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 && cached.status.linked) {
|
||||
_status = cached.status;
|
||||
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;
|
||||
_transactions = cached.transactions;
|
||||
_remoteTransactions = cached.transactions;
|
||||
}
|
||||
_importedTransactions = _storage?.loadImportedTransactions() ?? const [];
|
||||
}
|
||||
|
||||
final IngtrakApi _api;
|
||||
@@ -25,39 +37,112 @@ class BankController extends ChangeNotifier {
|
||||
|
||||
ConnectStatus _status = ConnectStatus.disconnected;
|
||||
BankBalance? _balance;
|
||||
List<BankTransaction> _transactions = const [];
|
||||
List<BankTransaction> _remoteTransactions = const [];
|
||||
List<BankTransaction> _importedTransactions = const [];
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isConnecting = false;
|
||||
bool _isImporting = false;
|
||||
String? _error;
|
||||
String? _pendingState;
|
||||
|
||||
ConnectStatus get status => _status;
|
||||
BankBalance? get balance => _balance;
|
||||
List<BankTransaction> get transactions => List.unmodifiable(_transactions);
|
||||
|
||||
/// Remote API txs merged with locally imported CSV txs (deduped).
|
||||
List<BankTransaction> get transactions =>
|
||||
mergeTransactions(_remoteTransactions, _importedTransactions);
|
||||
|
||||
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 (!_status.linked) {
|
||||
await storage.clearBankCache();
|
||||
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: _status,
|
||||
status: ConnectStatus(
|
||||
status: _status.status,
|
||||
linked: true,
|
||||
accountId: _status.accountId,
|
||||
sessionId: _status.sessionId,
|
||||
state: _status.state,
|
||||
validUntil: _status.validUntil,
|
||||
),
|
||||
balance: _balance,
|
||||
transactions: _transactions,
|
||||
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,
|
||||
);
|
||||
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 [];
|
||||
await _storage?.clearImportedTransactions();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Load status; if linked, also load balance + transactions.
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
@@ -65,25 +150,33 @@ class BankController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_status = await _api.fetchStatus();
|
||||
if (_status.linked) {
|
||||
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) {
|
||||
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)) {
|
||||
if (!hasData) {
|
||||
_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)) {
|
||||
if (!hasData) {
|
||||
_error = 'Could not reach $apiBaseUrl';
|
||||
} else {
|
||||
_error = 'Using saved data · offline/unreachable API';
|
||||
@@ -102,19 +195,35 @@ class BankController extends ChangeNotifier {
|
||||
_api.fetchTransactions(),
|
||||
]);
|
||||
_balance = results[0] as BankBalance;
|
||||
_transactions = results[1] as List<BankTransaction>;
|
||||
_remoteTransactions = results[1] as List<BankTransaction>;
|
||||
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,
|
||||
);
|
||||
await _persistImported();
|
||||
_error = null;
|
||||
} on IngtrakApiException catch (error) {
|
||||
if (error.isNotLinked) {
|
||||
// Confirmed unlinked by data endpoints — clear remote only.
|
||||
_status = ConnectStatus.disconnected;
|
||||
_balance = null;
|
||||
_transactions = const [];
|
||||
_remoteTransactions = const [];
|
||||
_error = error.message;
|
||||
await _persistCache();
|
||||
} else if (_balance == null && _transactions.isEmpty) {
|
||||
} else if (!hasData) {
|
||||
_error = error.message;
|
||||
} else {
|
||||
// Keep previous balance/txs if refresh of data fails but still linked.
|
||||
// Keep previous balance/txs if refresh of data fails.
|
||||
_error = 'Using saved data · ${error.message}';
|
||||
}
|
||||
}
|
||||
@@ -160,17 +269,16 @@ class BankController extends ChangeNotifier {
|
||||
return completeWithCode(code: code, state: state);
|
||||
}
|
||||
|
||||
// Backend already exchanged the code; just refresh local state.
|
||||
if (uri.queryParameters['linked'] == '1') {
|
||||
await refresh();
|
||||
if (!isLinked) {
|
||||
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;
|
||||
return isLinked || hasData;
|
||||
}
|
||||
|
||||
final redirectError = uri.queryParameters['error'];
|
||||
|
||||
Reference in New Issue
Block a user