diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 00804d4..806cabb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,8 @@ plugins { android { namespace = "com.example.myapp" - compileSdk = flutter.compileSdkVersion + // file_picker / flutter_plugin_android_lifecycle require API 36+. + compileSdk = 36 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..571d1b3 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -15,8 +15,25 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 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 { - 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("clean") { diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index ea0ae0c..f4d4110 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -207,7 +207,11 @@ class TxExactTemplate { /// Strip bank noise so "AH Campus … Transactie:123" matches other AH Campus txs. 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:')) { s = s.substring(5).trim(); final om = s.indexOf(' omschrijving:'); @@ -216,7 +220,7 @@ String merchantKeyFromDescription(String description) { const markers = [ ' pasvolgnr', - ' kaartnr', + 'kaartnr', ' datum:', ' tijd:', ' transactie:', diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index dee592f..1500546 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -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 _transactions = const []; + List _remoteTransactions = const []; + List _importedTransactions = const []; bool _isLoading = false; bool _isConnecting = false; + bool _isImporting = false; String? _error; String? _pendingState; ConnectStatus get status => _status; BankBalance? get balance => _balance; - List get transactions => List.unmodifiable(_transactions); + + /// Remote API txs merged with locally imported CSV txs (deduped). + List 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 _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 _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, + ); + 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 []; + await _storage?.clearImportedTransactions(); + notifyListeners(); + } + /// Load status; if linked, also load balance + transactions. Future 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; + _remoteTransactions = results[1] as List; + 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']; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index fa1382b..3cdeec5 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -54,11 +54,11 @@ class _HomeBody extends StatelessWidget { @override 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…'); } - if (!bank.isLinked) { + if (!bank.isLinked && !bank.hasData) { return ListView( physics: const AlwaysScrollableScrollPhysics(), 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( physics: const AlwaysScrollableScrollPhysics(), children: [ diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 66b2d6a..4682469 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -1,23 +1,19 @@ -/// Overview: current balance, charts, and look-ahead balance forecast. +/// Overview: monthly income/expense chart, summary cards, and day history. library; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/transaction_meta.dart'; +import '../models/bank.dart'; import '../providers/bank_provider.dart'; -import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; -import '../utils/look_ahead.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; -import 'label_transactions_screen.dart'; - -enum _StatsMode { category, month } +import 'transactions_screen.dart'; class OverviewScreen extends StatefulWidget { const OverviewScreen({super.key}); @@ -27,39 +23,11 @@ class OverviewScreen extends StatefulWidget { } class _OverviewScreenState extends State { - _StatsMode _mode = _StatsMode.category; - var _lookAheadEnabled = false; - DateTime _lookAheadMonth = monthStart(DateTime.now()); - DateTime _chartMonth = monthStart(DateTime.now()); - - void _goLookAheadMonth(int delta) { - final next = addMonths(_lookAheadMonth, delta); - final now = monthStart(DateTime.now()); - if (delta < 0 && next.isBefore(addMonths(now, -24))) return; - if (delta > 0 && next.isAfter(addMonths(now, 36))) return; - setState(() => _lookAheadMonth = next); - } - - void _goChartMonth(int delta) { - final next = addMonths(_chartMonth, delta); - final now = monthStart(DateTime.now()); - if (delta > 0 && next.isAfter(now)) return; - if (delta < 0 && next.isBefore(addMonths(now, -36))) return; - setState(() => _chartMonth = next.isAfter(now) ? now : next); - } - - void _openLabel(TxLabel label) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LabelTransactionsScreen(label: label), - ), - ); - } + static const _chartMonths = 6; @override Widget build(BuildContext context) { final bank = context.watch(); - final meta = context.watch(); return Scaffold( appBar: AppBar( @@ -73,17 +41,13 @@ class _OverviewScreenState extends State { body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: _buildBody(context, bank, meta), + child: _buildBody(context, bank), ), ), ); } - Widget _buildBody( - BuildContext context, - BankController bank, - TxMetaController meta, - ) { + Widget _buildBody(BuildContext context, BankController bank) { final theme = Theme.of(context); final colors = theme.colorScheme; @@ -91,7 +55,7 @@ class _OverviewScreenState extends State { return const LoadingView(message: 'Loading overview…'); } - if (!bank.isLinked) { + if (!bank.isLinked && !bank.hasData) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ @@ -108,502 +72,559 @@ class _OverviewScreenState extends State { (bank.transactions.isNotEmpty ? bank.transactions.first.currency : 'EUR'); - final currentBalance = bank.balance?.amount ?? 0; - final byLabel = meta.spendByLabel(bank.transactions); - final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth); - final totalSpend = meta.labeledSpendTotal(bank.transactions); - - final recurrings = recurringItemsFrom( - transactions: bank.transactions, - metaFor: meta.metaFor, - ); - final today = dateOnly(DateTime.now()); - final lookAheadEnd = DateTime( - _lookAheadMonth.year, - _lookAheadMonth.month + 1, - 0, - ); - final predicted = predictBalance( - currentBalance: currentBalance, - from: today, - throughDate: lookAheadEnd, - items: recurrings, - ); - final projected = projectedOccurrences( - items: recurrings, - from: today, - to: lookAheadEnd, - ); + final months = _lastMonths(_chartMonths); + final monthly = _monthlyTotals(bank.transactions, months); final nowMonth = monthStart(DateTime.now()); + final current = monthly[nowMonth] ?? + const _MonthBucket(income: 0, expense: 0); + final history = _monthHistory(bank.transactions); + final incomeColor = colors.primary; + final expenseColor = const Color(0xFF6FCF97); return ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 32), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 32), children: [ - Text('Current balance', style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - Text( - formatMoney(currentBalance, currency), - style: theme.textTheme.headlineMedium, - ), - const SizedBox(height: 20), - SwitchListTile( - contentPadding: EdgeInsets.zero, - secondary: const Icon(Icons.auto_graph_outlined), - title: const Text('Look ahead'), - subtitle: const Text( - 'Project balance from recurring income and spend', + if (bank.error != null) ...[ + Text( + bank.error!, + style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), ), - value: _lookAheadEnabled, - onChanged: (value) { - setState(() { - _lookAheadEnabled = value; - if (value && _lookAheadMonth.isBefore(nowMonth)) { - _lookAheadMonth = nowMonth; - } - }); - }, + const SizedBox(height: 12), + ], + _ChartCard( + months: months, + monthly: monthly, + currency: currency, + incomeColor: incomeColor, + expenseColor: expenseColor, ), - if (_lookAheadEnabled) ...[ - const SizedBox(height: 8), - Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - IconButton( - tooltip: 'Previous month', - onPressed: () => _goLookAheadMonth(-1), - icon: const Icon(Icons.chevron_left), - ), - Expanded( - child: Text( - formatMonthYear(_lookAheadMonth), - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - ), - IconButton( - tooltip: 'Next month', - onPressed: () => _goLookAheadMonth(1), - icon: const Icon(Icons.chevron_right), - ), - ], - ), - Text( - 'Predicted balance', - textAlign: TextAlign.center, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 4), - Text( - formatMoney(predicted, currency), - textAlign: TextAlign.center, - style: theme.textTheme.headlineMedium?.copyWith( - color: predicted >= currentBalance - ? colors.primary - : colors.error, - ), - ), - const SizedBox(height: 4), - Text( - _lookAheadMonth == nowMonth && !lookAheadEnd.isAfter(today) - ? 'Mark transactions as recurring to forecast' - : 'Through ${formatDate(lookAheadEnd)} · ' - '${projected.length} projected ' - '${projected.length == 1 ? 'item' : 'items'}', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - if (recurrings.isEmpty) ...[ - const SizedBox(height: 12), - Text( - 'No recurring transactions yet. Open a transaction and ' - 'enable Recurring.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium, - ), - ] else if (projected.isNotEmpty) ...[ - const SizedBox(height: 12), - for (final row in projected.take(12)) - ListTile( - dense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8), - leading: Builder( - builder: (context) { - final label = row.item.labelIds.isEmpty - ? null - : meta.labelById(row.item.labelIds.first); - return Icon( - label?.icon ?? Icons.autorenew, - color: label?.color, - ); - }, - ), - title: Text( - row.item.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text(formatDate(row.date)), - trailing: Text( - formatMoney(row.item.amount, row.item.currency), - style: theme.textTheme.titleSmall?.copyWith( - color: row.item.amount >= 0 - ? colors.primary - : colors.error, - ), - ), - ), - if (projected.length > 12) - Text( - '+${projected.length - 12} more…', - textAlign: TextAlign.center, - style: theme.textTheme.labelMedium, - ), - ], - ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _SummaryCard( + icon: Icons.savings_outlined, + iconBackground: incomeColor.withValues(alpha: 0.14), + iconColor: incomeColor, + amount: formatMoney(current.income, currency), + label: 'Income', ), ), - ), - ], - const SizedBox(height: 20), - Text('Money out (labeled)', style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - Text( - formatMoney(totalSpend, currency), - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 16), - SegmentedButton<_StatsMode>( - segments: const [ - ButtonSegment( - value: _StatsMode.category, - label: Text('Label'), - icon: Icon(Icons.label_outline), - ), - ButtonSegment( - value: _StatsMode.month, - label: Text('By day'), - icon: Icon(Icons.bar_chart_outlined), + const SizedBox(width: 12), + Expanded( + child: _SummaryCard( + icon: Icons.receipt_long_outlined, + iconBackground: expenseColor.withValues(alpha: 0.18), + iconColor: const Color(0xFF2F9E62), + amount: formatMoney(current.expense, currency), + label: 'Expenses', + ), ), ], - selected: {_mode}, - onSelectionChanged: (selection) { - setState(() => _mode = selection.first); - }, ), - if (_mode == _StatsMode.month) ...[ - const SizedBox(height: 8), - Row( - children: [ - IconButton( - tooltip: 'Previous month', - onPressed: () => _goChartMonth(-1), - icon: const Icon(Icons.chevron_left), - ), - Expanded( - child: Text( - formatMonthYear(_chartMonth), - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, - ), - ), - IconButton( - tooltip: 'Next month', - onPressed: _chartMonth.isBefore(nowMonth) - ? () => _goChartMonth(1) - : null, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ], - const SizedBox(height: 12), - SizedBox( - height: 260, - child: bank.transactions.isEmpty - ? Center( - child: Text( - 'Transactions will appear here once loaded.', - style: theme.textTheme.bodyMedium, - ), - ) - : _mode == _StatsMode.category - ? _LabelBars( - data: byLabel, - resolve: meta.labelById, - currency: currency, - onBarTap: _openLabel, - ) - : _DayBars(data: byDay, currency: currency), + const SizedBox(height: 8), + Text( + formatMonthYear(nowMonth), + style: theme.textTheme.labelMedium, ), const SizedBox(height: 20), - if (_mode == _StatsMode.category) - ..._labelLegend(context, byLabel, meta, currency) + Text('History', style: theme.textTheme.titleLarge), + const SizedBox(height: 12), + if (history.isEmpty) + Text( + 'No monthly activity to show.', + style: theme.textTheme.bodyMedium, + ) else - ..._dayLegend(context, byDay, currency), + for (final month in history) ...[ + _HistoryCard( + month: month.month, + income: month.income, + expense: month.expense, + currency: currency, + onTap: () => _openMonth(context, month.month), + ), + const SizedBox(height: 10), + ], ], ); } - List _labelLegend( - BuildContext context, - Map data, - TxMetaController meta, - String currency, - ) { - final entries = data.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - if (entries.isEmpty) { - return [ - Text( - 'No labeled outflows yet. Add a label from a transaction.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ]; - } - return [ - for (final entry in entries) - Builder( - builder: (context) { - final label = meta.labelById(entry.key); - if (label == null) return const SizedBox.shrink(); - return ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Icon(label.icon, color: label.color), - title: Text(label.name), - trailing: Text(formatMoney(entry.value, currency)), - onTap: () => _openLabel(label), - ); - }, - ), - ]; - } - - List _dayLegend( - BuildContext context, - Map data, - String currency, - ) { - final spent = data.entries.where((e) => e.value > 0).toList().reversed; - if (spent.isEmpty) { - return [ - Text( - 'No outflows this month.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ]; - } - return [ - for (final entry in spent) - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: const Icon(Icons.calendar_today_outlined), - title: Text(entry.key), - trailing: Text(formatMoney(entry.value, currency)), - ), - ]; + void _openMonth(BuildContext context, DateTime month) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionsScreen(initialMonth: month), + ), + ); } } -class _LabelBars extends StatelessWidget { - const _LabelBars({ - required this.data, - required this.resolve, +class _MonthBucket { + const _MonthBucket({required this.income, required this.expense}); + final double income; + final double expense; +} + +class _MonthHistoryRow { + const _MonthHistoryRow({ + required this.month, + required this.income, + required this.expense, + }); + final DateTime month; + final double income; + final double expense; +} + +List _lastMonths(int count) { + final now = monthStart(DateTime.now()); + return [ + for (var i = count - 1; i >= 0; i--) addMonths(now, -i), + ]; +} + +DateTime? _txDate(BankTransaction tx) { + final parsed = DateTime.tryParse(tx.date); + return parsed == null ? null : dateOnly(parsed); +} + +Map _monthlyTotals( + List txs, + List months, +) { + final totals = { + for (final month in months) month: (income: 0.0, expense: 0.0), + }; + for (final tx in txs) { + final date = _txDate(tx); + if (date == null) continue; + final key = monthStart(date); + final current = totals[key]; + if (current == null) continue; + if (tx.amount >= 0) { + totals[key] = (income: current.income + tx.amount, expense: current.expense); + } else { + totals[key] = ( + income: current.income, + expense: current.expense + tx.amount.abs(), + ); + } + } + return { + for (final e in totals.entries) + e.key: _MonthBucket(income: e.value.income, expense: e.value.expense), + }; +} + +/// Months that have activity, newest first. +List<_MonthHistoryRow> _monthHistory(List txs) { + final map = {}; + for (final tx in txs) { + final date = _txDate(tx); + if (date == null) continue; + final key = monthStart(date); + final current = map[key] ?? (income: 0.0, expense: 0.0); + if (tx.amount >= 0) { + map[key] = (income: current.income + tx.amount, expense: current.expense); + } else { + map[key] = ( + income: current.income, + expense: current.expense + tx.amount.abs(), + ); + } + } + final keys = map.keys.toList()..sort((a, b) => b.compareTo(a)); + return [ + for (final key in keys) + _MonthHistoryRow( + month: key, + income: map[key]!.income, + expense: map[key]!.expense, + ), + ]; +} + +class _ChartCard extends StatelessWidget { + const _ChartCard({ + required this.months, + required this.monthly, required this.currency, - required this.onBarTap, + required this.incomeColor, + required this.expenseColor, }); - final Map data; - final TxLabel? Function(String? id) resolve; + final List months; + final Map monthly; final String currency; - final void Function(TxLabel label) onBarTap; + final Color incomeColor; + final Color expenseColor; @override Widget build(BuildContext context) { - final entries = <({TxLabel label, double value})>[ - for (final entry in data.entries) - if (resolve(entry.key) != null) - (label: resolve(entry.key)!, value: entry.value), - ]..sort((a, b) => b.value.compareTo(a.value)); - - if (entries.isEmpty) { - return Center( - child: Text( - 'Label transactions to see this chart', - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ); + final theme = Theme.of(context); + final colors = theme.colorScheme; + var maxY = 0.0; + for (final month in months) { + final bucket = monthly[month]!; + if (bucket.income > maxY) maxY = bucket.income; + if (bucket.expense > maxY) maxY = bucket.expense; } - - final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); - return BarChart( - BarChartData( - maxY: maxY * 1.15, - barTouchData: BarTouchData( - touchCallback: (event, response) { - if (!event.isInterestedForInteractions) return; - final index = response?.spot?.touchedBarGroupIndex; - if (index == null || index < 0 || index >= entries.length) return; - if (event is FlTapUpEvent) { - onBarTap(entries[index].label); - } - }, - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final entry = entries[group.x.toInt()]; - return BarTooltipItem( - '${entry.label.name}\n${formatMoney(entry.value, currency)}', - TextStyle( - color: Theme.of(context).colorScheme.onInverseSurface, - ), - ); - }, - ), - ), - titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: - const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 42, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= entries.length) { - return const SizedBox.shrink(); - } - final label = entries[index].label.name; - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - label.length > 8 ? '${label.substring(0, 7)}…' : label, - style: Theme.of(context).textTheme.labelSmall, - ), - ); - }, - ), - ), - ), - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - barGroups: [ - for (var i = 0; i < entries.length; i++) - BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: entries[i].value, - width: 18, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(6)), - color: entries[i].label.color, - ), - ], - ), - ], - ), - ); - } -} - -class _DayBars extends StatelessWidget { - const _DayBars({required this.data, required this.currency}); - - final Map data; - final String currency; - - @override - Widget build(BuildContext context) { - final entries = data.entries.toList(); - if (entries.every((e) => e.value == 0)) { - return Center( - child: Text( - 'No spend this month', - style: Theme.of(context).textTheme.bodyMedium, - ), - ); - } - - final maxY = entries.map((e) => e.value).fold(0, (a, b) => a > b ? a : b); final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; - final colors = Theme.of(context).colorScheme; - return BarChart( - BarChartData( - maxY: safeMax, - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final entry = entries[group.x.toInt()]; - final day = entry.key.length >= 10 - ? entry.key.substring(8) - : entry.key; - return BarTooltipItem( - 'Day $day\n${formatMoney(entry.value, currency)}', - TextStyle(color: colors.onInverseSurface), - ); - }, - ), - ), - titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: - const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - interval: entries.length > 16 ? 5 : 2, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= entries.length) { - return const SizedBox.shrink(); - } - if (entries.length > 16 && index % 5 != 0) { - return const SizedBox.shrink(); - } - final day = (index + 1).toString(); - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - day, - style: Theme.of(context).textTheme.labelSmall, + + return Card( + color: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + children: [ + Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Monthly', style: theme.textTheme.labelLarge), + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 18, + color: colors.onSurfaceVariant, + ), + ], ), - ); - }, - ), - ), - ), - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - barGroups: [ - for (var i = 0; i < entries.length; i++) - BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: entries[i].value, - width: entries.length > 20 ? 6 : 8, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(3)), - color: entries[i].value > 0 - ? colors.primary - : colors.surfaceContainerHighest, ), + const Spacer(), + _LegendDot(color: incomeColor, label: 'Income'), + const SizedBox(width: 14), + _LegendDot(color: expenseColor, label: 'Expense'), ], ), - ], + const SizedBox(height: 16), + SizedBox( + height: 220, + child: months.every( + (m) => + monthly[m]!.income == 0 && monthly[m]!.expense == 0, + ) + ? Center( + child: Text( + 'No income or expenses in this period', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ) + : BarChart( + BarChartData( + maxY: safeMax, + groupsSpace: 18, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final month = months[group.x.toInt()]; + final bucket = monthly[month]!; + final isIncome = rodIndex == 1; + final value = + isIncome ? bucket.income : bucket.expense; + return BarTooltipItem( + '${_shortMonth(month)}\n' + '${isIncome ? 'Income' : 'Expense'}\n' + '${formatMoney(value, currency)}', + TextStyle(color: colors.onInverseSurface), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: safeMax / 3, + getTitlesWidget: (value, meta) { + if (value <= 0) { + return Text( + '0', + style: theme.textTheme.labelSmall, + ); + } + if ((value - safeMax).abs() < 0.01) { + return const SizedBox.shrink(); + } + return Text( + _compactMoney(value), + style: theme.textTheme.labelSmall, + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= months.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _shortMonth(months[index]), + style: theme.textTheme.labelSmall, + ), + ); + }, + ), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeMax / 3, + getDrawingHorizontalLine: (value) => FlLine( + color: colors.outlineVariant.withValues(alpha: 0.45), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barsSpace: 4, + barRods: [ + BarChartRodData( + toY: monthly[months[i]]!.expense, + width: 12, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + color: expenseColor, + ), + BarChartRodData( + toY: monthly[months[i]]!.income, + width: 12, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + color: incomeColor, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + static String _shortMonth(DateTime month) { + const names = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return names[month.month - 1]; + } + + static String _compactMoney(double value) { + if (value >= 1000) { + final k = value / 1000; + return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; + } + return value.round().toString(); + } +} + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.label}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelMedium), + ], + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.icon, + required this.iconBackground, + required this.iconColor, + required this.amount, + required this.label, + }); + + final IconData icon; + final Color iconBackground; + final Color iconColor; + final String amount; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Card( + color: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + amount, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text(label, style: theme.textTheme.labelMedium), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _HistoryCard extends StatelessWidget { + const _HistoryCard({ + required this.month, + required this.income, + required this.expense, + required this.currency, + required this.onTap, + }); + + final DateTime month; + final double income; + final double expense; + final String currency; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Card( + color: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Month', style: theme.textTheme.labelMedium), + const SizedBox(height: 2), + Text( + formatMonthYear(month), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + formatMoney(income, currency), + style: theme.textTheme.titleSmall?.copyWith( + color: const Color(0xFF2F9E62), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + formatMoney(-expense, currency), + style: theme.textTheme.titleSmall?.copyWith( + color: colors.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), ), ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 39762ec..2638d99 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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; +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -14,6 +17,95 @@ import '../widgets/responsive_body.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); + Future _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 _clearImports(BuildContext context, BankController bank) async { + final ok = await showDialog( + 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 Widget build(BuildContext context) { final themeController = context.watch(); @@ -109,6 +201,47 @@ class SettingsScreen extends StatelessWidget { style: theme.textTheme.bodyMedium, ), 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), const SizedBox(height: 8), Card( diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 23d7483..2c44920 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -19,7 +19,10 @@ import '../widgets/tx_heatmap.dart'; import 'transaction_detail_screen.dart'; 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 State createState() => _TransactionsScreenState(); @@ -28,11 +31,24 @@ class TransactionsScreen extends StatefulWidget { class _TransactionsScreenState extends State { final _searchController = TextEditingController(); String _query = ''; - DateTime _month = monthStart(DateTime.now()); + late DateTime _month; DateTime? _selectedDay; var _searchInMonthOnly = false; final Set _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 void dispose() { _searchController.dispose(); @@ -172,7 +188,7 @@ class _TransactionsScreenState extends State { return const LoadingView(message: 'Loading transactions…'); } - if (!bank.isLinked) { + if (!bank.isLinked && bank.transactions.isEmpty) { return ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ diff --git a/lib/services/ing_csv_import.dart b/lib/services/ing_csv_import.dart new file mode 100644 index 0000000..d2d0711 --- /dev/null +++ b/lib/services/ing_csv_import.dart @@ -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 added; + final int skippedDuplicates; + final List 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 mergeTransactions( + List existing, + List extra, +) { + final out = List.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 filterNewTransactions( + List existing, + List 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 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 = []; + 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 headers, List 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 _parseCsvLine(String line, String delimiter) { + final out = []; + 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 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); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index bf13abf..6c752dd 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -141,6 +141,42 @@ class StorageService { return _prefs.remove(StorageKeys.bankCache); } + List 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((e) => BankTransaction.fromJson(Map.from(e))) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Future saveImportedTransactions(List 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 clearImportedTransactions() { + return _prefs.remove(StorageKeys.importedTransactions); + } + TxMetaStoreData loadTxMetaStore() { final raw = _prefs.getString(StorageKeys.txMeta); if (raw != null && raw.isNotEmpty) { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index f6b4416..60dd8e4 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -45,6 +45,7 @@ abstract final class StorageKeys { static const txMeta = 'tx_meta_v2'; static const txMetaLegacy = 'tx_meta_v1'; static const bankCache = 'bank_cache_v1'; + static const importedTransactions = 'imported_txs_v1'; } /// Named routes registered on [MaterialApp]. diff --git a/pubspec.lock b/pubspec.lock index 6ef28b5..917bd92 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -134,6 +150,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -461,6 +485,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.26.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 14278ff..9e31319 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: app_links: ^7.2.1 webview_flutter: ^4.14.1 fl_chart: ^1.2.0 + file_picker: ^8.1.7 dev_dependencies: flutter_test: sdk: flutter @@ -92,3 +93,6 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +dependency_overrides: + flutter_plugin_android_lifecycle: 2.0.28 diff --git a/test/ing_csv_import_test.dart b/test/ing_csv_import_test.dart new file mode 100644 index 0000000..96154b7 --- /dev/null +++ b/test/ing_csv_import_test.dart @@ -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); + }); +}