ing csv import
This commit is contained in:
@@ -6,7 +6,8 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.myapp"
|
namespace = "com.example.myapp"
|
||||||
compileSdk = flutter.compileSdkVersion
|
// file_picker / flutter_plugin_android_lifecycle require API 36+.
|
||||||
|
compileSdk = 36
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -15,8 +15,25 @@ subprojects {
|
|||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force plugins (e.g. file_picker) onto compileSdk 36 so they satisfy
|
||||||
|
// flutter_plugin_android_lifecycle AAR metadata checks.
|
||||||
subprojects {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
afterEvaluate {
|
||||||
|
val android = extensions.findByName("android") ?: return@afterEvaluate
|
||||||
|
for (name in listOf("setCompileSdk", "setCompileSdkVersion")) {
|
||||||
|
try {
|
||||||
|
android.javaClass
|
||||||
|
.getMethod(name, Int::class.javaPrimitiveType)
|
||||||
|
.invoke(android, 36)
|
||||||
|
break
|
||||||
|
} catch (_: NoSuchMethodException) {
|
||||||
|
// try next
|
||||||
|
} catch (_: Exception) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
|
|||||||
@@ -207,7 +207,11 @@ class TxExactTemplate {
|
|||||||
|
|
||||||
/// Strip bank noise so "AH Campus … Transactie:123" matches other AH Campus txs.
|
/// Strip bank noise so "AH Campus … Transactie:123" matches other AH Campus txs.
|
||||||
String merchantKeyFromDescription(String description) {
|
String merchantKeyFromDescription(String description) {
|
||||||
var s = description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
var s = description
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('·', ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
if (s.startsWith('naam:')) {
|
if (s.startsWith('naam:')) {
|
||||||
s = s.substring(5).trim();
|
s = s.substring(5).trim();
|
||||||
final om = s.indexOf(' omschrijving:');
|
final om = s.indexOf(' omschrijving:');
|
||||||
|
|||||||
@@ -4,20 +4,32 @@ library;
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/bank.dart';
|
import '../models/bank.dart';
|
||||||
|
import '../services/ing_csv_import.dart';
|
||||||
import '../services/ingtrak_api.dart';
|
import '../services/ingtrak_api.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../utils/constants.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 {
|
class BankController extends ChangeNotifier {
|
||||||
BankController({IngtrakApi? api, this._storage})
|
BankController({IngtrakApi? api, this._storage})
|
||||||
: _api = api ?? IngtrakApi() {
|
: _api = api ?? IngtrakApi() {
|
||||||
final cached = _storage?.loadBankCache();
|
final cached = _storage?.loadBankCache();
|
||||||
if (cached != null && cached.status.linked) {
|
if (cached != null) {
|
||||||
_status = cached.status;
|
// 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;
|
_balance = cached.balance;
|
||||||
_transactions = cached.transactions;
|
_remoteTransactions = cached.transactions;
|
||||||
}
|
}
|
||||||
|
_importedTransactions = _storage?.loadImportedTransactions() ?? const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final IngtrakApi _api;
|
final IngtrakApi _api;
|
||||||
@@ -25,65 +37,146 @@ class BankController extends ChangeNotifier {
|
|||||||
|
|
||||||
ConnectStatus _status = ConnectStatus.disconnected;
|
ConnectStatus _status = ConnectStatus.disconnected;
|
||||||
BankBalance? _balance;
|
BankBalance? _balance;
|
||||||
List<BankTransaction> _transactions = const [];
|
List<BankTransaction> _remoteTransactions = const [];
|
||||||
|
List<BankTransaction> _importedTransactions = const [];
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
|
bool _isImporting = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
String? _pendingState;
|
String? _pendingState;
|
||||||
|
|
||||||
ConnectStatus get status => _status;
|
ConnectStatus get status => _status;
|
||||||
BankBalance? get balance => _balance;
|
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 isLoading => _isLoading;
|
||||||
bool get isConnecting => _isConnecting;
|
bool get isConnecting => _isConnecting;
|
||||||
|
bool get isImporting => _isImporting;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
bool get isLinked => _status.linked;
|
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 apiBaseUrl => _api.baseUrl;
|
||||||
String? get pendingState => _pendingState;
|
String? get pendingState => _pendingState;
|
||||||
|
|
||||||
Future<void> _persistCache() async {
|
Future<void> _persistCache() async {
|
||||||
final storage = _storage;
|
final storage = _storage;
|
||||||
if (storage == null) return;
|
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) {
|
if (!_status.linked) {
|
||||||
await storage.clearBankCache();
|
await storage.clearBankCache();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await storage.saveBankCache(
|
await storage.saveBankCache(
|
||||||
BankCache(
|
BankCache(
|
||||||
status: _status,
|
status: ConnectStatus(
|
||||||
|
status: _status.status,
|
||||||
|
linked: true,
|
||||||
|
accountId: _status.accountId,
|
||||||
|
sessionId: _status.sessionId,
|
||||||
|
state: _status.state,
|
||||||
|
validUntil: _status.validUntil,
|
||||||
|
),
|
||||||
balance: _balance,
|
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.
|
/// Load status; if linked, also load balance + transactions.
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
try {
|
try {
|
||||||
_status = await _api.fetchStatus();
|
_status = await _api.fetchStatus();
|
||||||
if (_status.linked) {
|
} 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 _loadLinkedData();
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
} else {
|
|
||||||
_balance = null;
|
|
||||||
_transactions = const [];
|
|
||||||
await _persistCache();
|
|
||||||
}
|
}
|
||||||
} on IngtrakApiException catch (error, stackTrace) {
|
} on IngtrakApiException catch (error, stackTrace) {
|
||||||
// Keep cached linked data on transient API errors.
|
if (!hasData) {
|
||||||
if (!isLinked || (_balance == null && _transactions.isEmpty)) {
|
|
||||||
_error = error.message;
|
_error = error.message;
|
||||||
} else {
|
} else {
|
||||||
_error = 'Using saved data · ${error.message}';
|
_error = 'Using saved data · ${error.message}';
|
||||||
}
|
}
|
||||||
debugPrint('BankController.refresh failed: $error\n$stackTrace');
|
debugPrint('BankController.refresh failed: $error\n$stackTrace');
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
if (!isLinked || (_balance == null && _transactions.isEmpty)) {
|
if (!hasData) {
|
||||||
_error = 'Could not reach $apiBaseUrl';
|
_error = 'Could not reach $apiBaseUrl';
|
||||||
} else {
|
} else {
|
||||||
_error = 'Using saved data · offline/unreachable API';
|
_error = 'Using saved data · offline/unreachable API';
|
||||||
@@ -102,19 +195,35 @@ class BankController extends ChangeNotifier {
|
|||||||
_api.fetchTransactions(),
|
_api.fetchTransactions(),
|
||||||
]);
|
]);
|
||||||
_balance = results[0] as BankBalance;
|
_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;
|
_error = null;
|
||||||
} on IngtrakApiException catch (error) {
|
} on IngtrakApiException catch (error) {
|
||||||
if (error.isNotLinked) {
|
if (error.isNotLinked) {
|
||||||
|
// Confirmed unlinked by data endpoints — clear remote only.
|
||||||
_status = ConnectStatus.disconnected;
|
_status = ConnectStatus.disconnected;
|
||||||
_balance = null;
|
_balance = null;
|
||||||
_transactions = const [];
|
_remoteTransactions = const [];
|
||||||
_error = error.message;
|
_error = error.message;
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
} else if (_balance == null && _transactions.isEmpty) {
|
} else if (!hasData) {
|
||||||
_error = error.message;
|
_error = error.message;
|
||||||
} else {
|
} 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}';
|
_error = 'Using saved data · ${error.message}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,17 +269,16 @@ class BankController extends ChangeNotifier {
|
|||||||
return completeWithCode(code: code, state: state);
|
return completeWithCode(code: code, state: state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend already exchanged the code; just refresh local state.
|
|
||||||
if (uri.queryParameters['linked'] == '1') {
|
if (uri.queryParameters['linked'] == '1') {
|
||||||
await refresh();
|
await refresh();
|
||||||
if (!isLinked) {
|
if (!isLinked && !hasData) {
|
||||||
_error =
|
_error =
|
||||||
'Returned to the app, but the backend still has no linked session. '
|
'Returned to the app, but the backend still has no linked session. '
|
||||||
'Finish login inside the in-app WebView (do not open the email '
|
'Finish login inside the in-app WebView (do not open the email '
|
||||||
'link in another browser), then pull to refresh.';
|
'link in another browser), then pull to refresh.';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return isLinked;
|
return isLinked || hasData;
|
||||||
}
|
}
|
||||||
|
|
||||||
final redirectError = uri.queryParameters['error'];
|
final redirectError = uri.queryParameters['error'];
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ class _HomeBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (bank.isLoading && bank.balance == null && !bank.isLinked) {
|
if (bank.isLoading && bank.balance == null && !bank.hasData) {
|
||||||
return const LoadingView(message: 'Checking bank link…');
|
return const LoadingView(message: 'Checking bank link…');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bank.isLinked) {
|
if (!bank.isLinked && !bank.hasData) {
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
@@ -74,7 +74,7 @@ class _HomeBody extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bank.error != null && bank.balance == null) {
|
if (bank.error != null && bank.balance == null && !bank.hasData) {
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
+491
-470
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
/// Settings tab: theme mode, API endpoint, and bank link status.
|
/// Settings tab: theme mode, API endpoint, bank link, and CSV import.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -14,6 +17,95 @@ import '../widgets/responsive_body.dart';
|
|||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _importCsv(BuildContext context, BankController bank) async {
|
||||||
|
// Use FileType.any — Android often greys out real ING exports when
|
||||||
|
// filtering to csv/txt (odd MIME types / no extension / Downloads).
|
||||||
|
final picked = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.any,
|
||||||
|
withData: true,
|
||||||
|
allowMultiple: false,
|
||||||
|
);
|
||||||
|
if (picked == null || picked.files.isEmpty) return;
|
||||||
|
|
||||||
|
final file = picked.files.single;
|
||||||
|
var bytes = file.bytes;
|
||||||
|
if ((bytes == null || bytes.isEmpty) && file.path != null) {
|
||||||
|
bytes = await file.xFile.readAsBytes();
|
||||||
|
}
|
||||||
|
if (bytes == null || bytes.isEmpty) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not read the selected file')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String raw;
|
||||||
|
try {
|
||||||
|
raw = utf8.decode(bytes);
|
||||||
|
} catch (_) {
|
||||||
|
raw = latin1.decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await bank.importIngCsv(raw);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
result.added.isEmpty
|
||||||
|
? 'No new transactions '
|
||||||
|
'(${result.skippedDuplicates} already in the app)'
|
||||||
|
: 'Imported ${result.added.length} new'
|
||||||
|
'${result.skippedDuplicates > 0 ? ' · skipped ${result.skippedDuplicates} duplicates' : ''}'
|
||||||
|
' of ${result.parsed} CSV rows',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.message)),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(bank.error ?? 'Import failed')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearImports(BuildContext context, BankController bank) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Clear imported CSV?'),
|
||||||
|
content: Text(
|
||||||
|
'Removes ${bank.importedCount} locally imported transactions. '
|
||||||
|
'Bank-linked data is kept.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (ok != true || !context.mounted) return;
|
||||||
|
await bank.clearImportedTransactions();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Imported transactions cleared')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeController = context.watch<ThemeController>();
|
final themeController = context.watch<ThemeController>();
|
||||||
@@ -109,6 +201,47 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
Text('CSV import', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.upload_file_outlined),
|
||||||
|
title: const Text('Import ING CSV'),
|
||||||
|
subtitle: Text(
|
||||||
|
bank.isImporting
|
||||||
|
? 'Importing…'
|
||||||
|
: bank.importedCount == 0
|
||||||
|
? 'Adds older history without duplicating bank feed'
|
||||||
|
: '${bank.importedCount} imported rows kept locally',
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: bank.isImporting
|
||||||
|
? null
|
||||||
|
: () => _importCsv(context, bank),
|
||||||
|
child: Text(bank.isImporting ? '…' : 'Import'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bank.importedCount > 0) ...[
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline),
|
||||||
|
title: const Text('Clear imported CSV'),
|
||||||
|
onTap: () => _clearImports(context, bank),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Supports ING Netherlands exports (Dutch or English headers, '
|
||||||
|
'comma or semicolon). Matching uses date + amount + merchant, '
|
||||||
|
'so overlaps with the live feed are skipped.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
Text('About', style: theme.textTheme.titleMedium),
|
Text('About', style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import '../widgets/tx_heatmap.dart';
|
|||||||
import 'transaction_detail_screen.dart';
|
import 'transaction_detail_screen.dart';
|
||||||
|
|
||||||
class TransactionsScreen extends StatefulWidget {
|
class TransactionsScreen extends StatefulWidget {
|
||||||
const TransactionsScreen({super.key});
|
const TransactionsScreen({super.key, this.initialMonth});
|
||||||
|
|
||||||
|
/// When set, opens on this calendar month instead of the current month.
|
||||||
|
final DateTime? initialMonth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TransactionsScreen> createState() => _TransactionsScreenState();
|
State<TransactionsScreen> createState() => _TransactionsScreenState();
|
||||||
@@ -28,11 +31,24 @@ class TransactionsScreen extends StatefulWidget {
|
|||||||
class _TransactionsScreenState extends State<TransactionsScreen> {
|
class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
String _query = '';
|
String _query = '';
|
||||||
DateTime _month = monthStart(DateTime.now());
|
late DateTime _month;
|
||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
var _searchInMonthOnly = false;
|
var _searchInMonthOnly = false;
|
||||||
final Set<String> _labelFilters = {};
|
final Set<String> _labelFilters = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialMonth;
|
||||||
|
final now = monthStart(DateTime.now());
|
||||||
|
if (initial == null) {
|
||||||
|
_month = now;
|
||||||
|
} else {
|
||||||
|
final start = monthStart(initial);
|
||||||
|
_month = start.isAfter(now) ? now : start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -172,7 +188,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
|||||||
return const LoadingView(message: 'Loading transactions…');
|
return const LoadingView(message: 'Loading transactions…');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bank.isLinked) {
|
if (!bank.isLinked && bank.transactions.isEmpty) {
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/// Parse ING Netherlands (and similar) bank CSV exports into [BankTransaction]s.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import '../models/bank.dart';
|
||||||
|
import '../models/transaction_meta.dart';
|
||||||
|
|
||||||
|
/// Result of importing rows against an existing transaction list.
|
||||||
|
class CsvImportResult {
|
||||||
|
const CsvImportResult({
|
||||||
|
required this.parsed,
|
||||||
|
required this.added,
|
||||||
|
required this.skippedDuplicates,
|
||||||
|
this.parseErrors = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final int parsed;
|
||||||
|
final List<BankTransaction> added;
|
||||||
|
final int skippedDuplicates;
|
||||||
|
final List<String> parseErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when [a] and [b] likely represent the same bank movement.
|
||||||
|
///
|
||||||
|
/// Matches on booking date + amount, then description / merchant fingerprint
|
||||||
|
/// so API rows and CSV rows can still dedupe when wording differs slightly.
|
||||||
|
bool isSameTransaction(BankTransaction a, BankTransaction b) {
|
||||||
|
if (a.date != b.date) return false;
|
||||||
|
if (a.currency.toUpperCase() != b.currency.toUpperCase()) return false;
|
||||||
|
if (_amountCents(a.amount) != _amountCents(b.amount)) return false;
|
||||||
|
|
||||||
|
if (a.descriptionKey == b.descriptionKey) return true;
|
||||||
|
|
||||||
|
final ma = a.merchantKey;
|
||||||
|
final mb = b.merchantKey;
|
||||||
|
if (ma.isNotEmpty && mb.isNotEmpty) {
|
||||||
|
if (ma == mb) return true;
|
||||||
|
if (ma.contains(mb) || mb.contains(ma)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV often puts the short name in description and details in mededelingen;
|
||||||
|
// API may embed everything. Compare stripped merchant against full key.
|
||||||
|
if (ma.isNotEmpty && b.descriptionKey.contains(ma)) return true;
|
||||||
|
if (mb.isNotEmpty && a.descriptionKey.contains(mb)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _amountCents(double amount) => (amount * 100).round();
|
||||||
|
|
||||||
|
/// Merge [extra] into [existing], skipping duplicates. Newest date first.
|
||||||
|
List<BankTransaction> mergeTransactions(
|
||||||
|
List<BankTransaction> existing,
|
||||||
|
List<BankTransaction> extra,
|
||||||
|
) {
|
||||||
|
final out = List<BankTransaction>.of(existing);
|
||||||
|
for (final tx in extra) {
|
||||||
|
if (out.any((e) => isSameTransaction(e, tx))) continue;
|
||||||
|
out.add(tx);
|
||||||
|
}
|
||||||
|
out.sort((a, b) {
|
||||||
|
final byDate = b.date.compareTo(a.date);
|
||||||
|
if (byDate != 0) return byDate;
|
||||||
|
return a.description.compareTo(b.description);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only rows from [candidates] that are not already in [existing].
|
||||||
|
List<BankTransaction> filterNewTransactions(
|
||||||
|
List<BankTransaction> existing,
|
||||||
|
List<BankTransaction> candidates,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
for (final tx in candidates)
|
||||||
|
if (!existing.any((e) => isSameTransaction(e, tx))) tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an ING (NL/EN) CSV/semicolon export.
|
||||||
|
///
|
||||||
|
/// Expected columns (Dutch or English):
|
||||||
|
/// Date, Name/Description, Account, Counterparty, Code, Debit/Credit (Af/Bij),
|
||||||
|
/// Amount, Transaction type, Notifications, …
|
||||||
|
List<BankTransaction> parseIngCsv(String raw) {
|
||||||
|
final text = raw.replaceFirst(RegExp(r'^\uFEFF'), '').trim();
|
||||||
|
if (text.isEmpty) return const [];
|
||||||
|
|
||||||
|
final lines = text.split(RegExp(r'\r?\n'));
|
||||||
|
if (lines.isEmpty) return const [];
|
||||||
|
|
||||||
|
final headerLine = lines.first;
|
||||||
|
final delimiter = _detectDelimiter(headerLine);
|
||||||
|
final headers = _parseCsvLine(headerLine, delimiter)
|
||||||
|
.map(_normalizeHeader)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final dateIdx = _indexOfAny(headers, const [
|
||||||
|
'datum',
|
||||||
|
'date',
|
||||||
|
]);
|
||||||
|
final nameIdx = _indexOfAny(headers, const [
|
||||||
|
'naam / omschrijving',
|
||||||
|
'naam/omschrijving',
|
||||||
|
'name / description',
|
||||||
|
'name/description',
|
||||||
|
'omschrijving',
|
||||||
|
'description',
|
||||||
|
]);
|
||||||
|
final afBijIdx = _indexOfAny(headers, const [
|
||||||
|
'af bij',
|
||||||
|
'af/bij',
|
||||||
|
'debit/credit',
|
||||||
|
'debit / credit',
|
||||||
|
]);
|
||||||
|
final amountIdx = _indexOfAny(headers, const [
|
||||||
|
'bedrag (eur)',
|
||||||
|
'bedrag',
|
||||||
|
'amount (eur)',
|
||||||
|
'amount',
|
||||||
|
]);
|
||||||
|
final notesIdx = _indexOfAny(headers, const [
|
||||||
|
'mededelingen',
|
||||||
|
'notifications',
|
||||||
|
'notification',
|
||||||
|
]);
|
||||||
|
final currencyIdx = _indexOfAny(headers, const [
|
||||||
|
'valuta',
|
||||||
|
'currency',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (dateIdx < 0 || amountIdx < 0) {
|
||||||
|
throw const FormatException(
|
||||||
|
'CSV needs at least Date/Datum and Amount/Bedrag columns '
|
||||||
|
'(ING Netherlands export).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final out = <BankTransaction>[];
|
||||||
|
for (var i = 1; i < lines.length; i++) {
|
||||||
|
final line = lines[i].trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
final cols = _parseCsvLine(line, delimiter);
|
||||||
|
if (cols.length <= amountIdx || cols.length <= dateIdx) continue;
|
||||||
|
|
||||||
|
final date = _parseIngDate(cols[dateIdx]);
|
||||||
|
if (date == null) continue;
|
||||||
|
|
||||||
|
final amountRaw = _parseIngAmount(cols[amountIdx]);
|
||||||
|
if (amountRaw == null) continue;
|
||||||
|
|
||||||
|
final afBij = afBijIdx >= 0 && afBijIdx < cols.length
|
||||||
|
? cols[afBijIdx].trim().toLowerCase()
|
||||||
|
: '';
|
||||||
|
final signed = _applyDebitCredit(amountRaw, afBij);
|
||||||
|
|
||||||
|
final name = nameIdx >= 0 && nameIdx < cols.length ? cols[nameIdx].trim() : '';
|
||||||
|
final notes =
|
||||||
|
notesIdx >= 0 && notesIdx < cols.length ? cols[notesIdx].trim() : '';
|
||||||
|
final description = [
|
||||||
|
if (name.isNotEmpty) name,
|
||||||
|
if (notes.isNotEmpty) notes,
|
||||||
|
].join(' · ');
|
||||||
|
if (description.isEmpty) continue;
|
||||||
|
|
||||||
|
final currency = currencyIdx >= 0 && currencyIdx < cols.length
|
||||||
|
? cols[currencyIdx].trim().toUpperCase()
|
||||||
|
: 'EUR';
|
||||||
|
|
||||||
|
out.add(
|
||||||
|
BankTransaction(
|
||||||
|
date: date,
|
||||||
|
description: description,
|
||||||
|
amount: signed,
|
||||||
|
currency: currency.isEmpty ? 'EUR' : currency,
|
||||||
|
pending: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeHeader(String value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('"', '')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
int _indexOfAny(List<String> headers, List<String> candidates) {
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
final index = headers.indexOf(candidate);
|
||||||
|
if (index >= 0) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _detectDelimiter(String headerLine) {
|
||||||
|
final semis = ';'.allMatches(headerLine).length;
|
||||||
|
final commas = ','.allMatches(headerLine).length;
|
||||||
|
return semis > commas ? ';' : ',';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseCsvLine(String line, String delimiter) {
|
||||||
|
final out = <String>[];
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var inQuotes = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < line.length; i++) {
|
||||||
|
final ch = line[i];
|
||||||
|
if (ch == '"') {
|
||||||
|
if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
|
||||||
|
buffer.write('"');
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch == delimiter && !inQuotes) {
|
||||||
|
out.add(buffer.toString());
|
||||||
|
buffer.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buffer.write(ch);
|
||||||
|
}
|
||||||
|
out.add(buffer.toString());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts `YYYYMMDD`, `YYYY-MM-DD`, `DD-MM-YYYY`, `DD/MM/YYYY`.
|
||||||
|
String? _parseIngDate(String raw) {
|
||||||
|
final value = raw.trim().replaceAll('"', '');
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
|
||||||
|
final yyyymmdd = RegExp(r'^(\d{4})(\d{2})(\d{2})$').firstMatch(value);
|
||||||
|
if (yyyymmdd != null) {
|
||||||
|
return '${yyyymmdd[1]}-${yyyymmdd[2]}-${yyyymmdd[3]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})').firstMatch(value);
|
||||||
|
if (iso != null) {
|
||||||
|
return '${iso[1]}-${iso[2]}-${iso[3]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
final dmy = RegExp(r'^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$').firstMatch(value);
|
||||||
|
if (dmy != null) {
|
||||||
|
final d = dmy[1]!.padLeft(2, '0');
|
||||||
|
final m = dmy[2]!.padLeft(2, '0');
|
||||||
|
return '${dmy[3]}-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = DateTime.tryParse(value);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
final m = parsed.month.toString().padLeft(2, '0');
|
||||||
|
final d = parsed.day.toString().padLeft(2, '0');
|
||||||
|
return '${parsed.year}-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _parseIngAmount(String raw) {
|
||||||
|
var value = raw.trim().replaceAll('"', '').replaceAll('\u00a0', '');
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
// European: 1.234,56 or 12,34 — strip thousands dots when comma present.
|
||||||
|
if (value.contains(',') && value.contains('.')) {
|
||||||
|
value = value.replaceAll('.', '').replaceAll(',', '.');
|
||||||
|
} else if (value.contains(',')) {
|
||||||
|
value = value.replaceAll(',', '.');
|
||||||
|
}
|
||||||
|
return double.tryParse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _applyDebitCredit(double amount, String afBij) {
|
||||||
|
final abs = amount.abs();
|
||||||
|
if (afBij == 'af' || afBij == 'debit' || afBij == 'd') return -abs;
|
||||||
|
if (afBij == 'bij' || afBij == 'credit' || afBij == 'c') return abs;
|
||||||
|
// Already signed amounts (some exports).
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: parse + keep only rows not already present.
|
||||||
|
CsvImportResult importIngCsvAgainst({
|
||||||
|
required String raw,
|
||||||
|
required List<BankTransaction> existing,
|
||||||
|
}) {
|
||||||
|
final parsed = parseIngCsv(raw);
|
||||||
|
final added = filterNewTransactions(existing, parsed);
|
||||||
|
return CsvImportResult(
|
||||||
|
parsed: parsed.length,
|
||||||
|
added: added,
|
||||||
|
skippedDuplicates: parsed.length - added.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expose merchant helper for tests without re-export noise.
|
||||||
|
String debugMerchantKey(String description) =>
|
||||||
|
merchantKeyFromDescription(description);
|
||||||
@@ -141,6 +141,42 @@ class StorageService {
|
|||||||
return _prefs.remove(StorageKeys.bankCache);
|
return _prefs.remove(StorageKeys.bankCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<BankTransaction> loadImportedTransactions() {
|
||||||
|
final raw = _prefs.getString(StorageKeys.importedTransactions);
|
||||||
|
if (raw == null || raw.isEmpty) return const [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) return const [];
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => BankTransaction.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList(growable: false);
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveImportedTransactions(List<BankTransaction> txs) {
|
||||||
|
final encoded = [
|
||||||
|
for (final tx in txs)
|
||||||
|
{
|
||||||
|
'date': tx.date,
|
||||||
|
'description': tx.description,
|
||||||
|
'amount': tx.amount,
|
||||||
|
'currency': tx.currency,
|
||||||
|
'pending': tx.pending,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return _prefs.setString(
|
||||||
|
StorageKeys.importedTransactions,
|
||||||
|
jsonEncode(encoded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearImportedTransactions() {
|
||||||
|
return _prefs.remove(StorageKeys.importedTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
TxMetaStoreData loadTxMetaStore() {
|
TxMetaStoreData loadTxMetaStore() {
|
||||||
final raw = _prefs.getString(StorageKeys.txMeta);
|
final raw = _prefs.getString(StorageKeys.txMeta);
|
||||||
if (raw != null && raw.isNotEmpty) {
|
if (raw != null && raw.isNotEmpty) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ abstract final class StorageKeys {
|
|||||||
static const txMeta = 'tx_meta_v2';
|
static const txMeta = 'tx_meta_v2';
|
||||||
static const txMetaLegacy = 'tx_meta_v1';
|
static const txMetaLegacy = 'tx_meta_v1';
|
||||||
static const bankCache = 'bank_cache_v1';
|
static const bankCache = 'bank_cache_v1';
|
||||||
|
static const importedTransactions = 'imported_txs_v1';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Named routes registered on [MaterialApp].
|
/// Named routes registered on [MaterialApp].
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -113,6 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.7"
|
||||||
fl_chart:
|
fl_chart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -134,6 +150,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: "direct overridden"
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.28"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -461,6 +485,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.26.1"
|
version: "3.26.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.15.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dependencies:
|
|||||||
app_links: ^7.2.1
|
app_links: ^7.2.1
|
||||||
webview_flutter: ^4.14.1
|
webview_flutter: ^4.14.1
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
|
file_picker: ^8.1.7
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@@ -92,3 +93,6 @@ flutter:
|
|||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# see https://flutter.dev/to/font-from-package
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
flutter_plugin_android_lifecycle: 2.0.28
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:myapp/models/bank.dart';
|
||||||
|
import 'package:myapp/services/ing_csv_import.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('parses ING Dutch semicolon CSV with Af/Bij and YYYYMMDD', () {
|
||||||
|
const raw = '''
|
||||||
|
"Datum";"Naam / Omschrijving";"Rekening";"Tegenrekening";"Code";"Af Bij";"Bedrag (EUR)";"Mutatiesoort";"Mededelingen"
|
||||||
|
"20260901";"AH CAMPUS DIEMEN";"NL00INGB0000000000";"";"GT";"Af";"12,50";"Betaalautomaat";"Pasvolgnr: 001"
|
||||||
|
"20260902";"SALARIS";"NL00INGB0000000000";"NL00ABCD";"OV";"Bij";"2000,00";"Overboeking";"Salaris september"
|
||||||
|
''';
|
||||||
|
final txs = parseIngCsv(raw);
|
||||||
|
expect(txs, hasLength(2));
|
||||||
|
expect(txs[0].date, '2026-09-01');
|
||||||
|
expect(txs[0].amount, -12.5);
|
||||||
|
expect(txs[0].description, contains('AH CAMPUS'));
|
||||||
|
expect(txs[1].date, '2026-09-02');
|
||||||
|
expect(txs[1].amount, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips duplicates against existing API-like rows', () {
|
||||||
|
final existing = [
|
||||||
|
const BankTransaction(
|
||||||
|
date: '2026-09-01',
|
||||||
|
description:
|
||||||
|
'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: C00338',
|
||||||
|
amount: -12.5,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const raw = '''
|
||||||
|
Datum,Naam / Omschrijving,Rekening,Tegenrekening,Code,Af Bij,Bedrag (EUR),Mutatiesoort,Mededelingen
|
||||||
|
20260901,AH CAMPUS DIEMEN,,,,Af,"12,50",Betaalautomaat,Pasvolgnr: 001
|
||||||
|
20260903,JUMBO,,,,Af,"8,00",Betaalautomaat,
|
||||||
|
''';
|
||||||
|
final result = importIngCsvAgainst(raw: raw, existing: existing);
|
||||||
|
expect(result.parsed, 2);
|
||||||
|
expect(result.skippedDuplicates, 1);
|
||||||
|
expect(result.added, hasLength(1));
|
||||||
|
expect(result.added.single.description, contains('JUMBO'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSameTransaction matches merchant fingerprints', () {
|
||||||
|
const api = BankTransaction(
|
||||||
|
date: '2026-09-01',
|
||||||
|
description: 'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: X',
|
||||||
|
amount: -3.5,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
);
|
||||||
|
const csv = BankTransaction(
|
||||||
|
date: '2026-09-01',
|
||||||
|
description: 'AH CAMPUS DIEMEN · Pasvolgnr: 001',
|
||||||
|
amount: -3.5,
|
||||||
|
currency: 'EUR',
|
||||||
|
pending: false,
|
||||||
|
);
|
||||||
|
expect(isSameTransaction(api, csv), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user