diff --git a/lib/main.dart b/lib/main.dart index 83e9021..8794e4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,7 +43,7 @@ class _MyAppState extends State { @override void initState() { super.initState(); - _bank = BankController()..refresh(); + _bank = BankController(storage: widget.storage)..refresh(); _appLinks = AppLinks(); _listenForBankRedirects(); } diff --git a/lib/models/bank.dart b/lib/models/bank.dart index 8a398bb..dad715d 100644 --- a/lib/models/bank.dart +++ b/lib/models/bank.dart @@ -1,6 +1,8 @@ /// Domain models for the personal ingtrak bank-data API. library; +import 'transaction_meta.dart' show merchantKeyFromDescription; + /// Result of `POST /api/connect`. class ConnectStart { const ConnectStart({required this.authUrl, required this.state}); @@ -101,6 +103,9 @@ class BankTransaction { String get descriptionKey => description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + /// Fuzzy merchant key — strips dates / terminal / SEPA noise. + String get merchantKey => merchantKeyFromDescription(description); + /// Exact match for copying notes (same description + amount + currency). String get exactKey => '$descriptionKey|$amount|$currency'; diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index 968d76f..ea0ae0c 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -1,119 +1,190 @@ -/// Local transaction categories and per-transaction annotations. +/// Transaction labels (built-in + user-created) and local annotations. library; import 'package:flutter/material.dart'; -/// User-facing spend / income categories (stored locally, not on the bank API). -enum TxCategory { - uncategorized, - groceries, - energy, - salary, - mortgage, - transport, - dining, - shopping, - health, - transfer, - other, -} +/// A spend/income label shown on transactions. +class TxLabel { + const TxLabel({ + required this.id, + required this.name, + required this.color, + this.icon = Icons.label_outline, + this.custom = false, + }); -extension TxCategoryX on TxCategory { - String get label => switch (this) { - TxCategory.uncategorized => 'Uncategorized', - TxCategory.groceries => 'Groceries', - TxCategory.energy => 'Energy', - TxCategory.salary => 'Salary', - TxCategory.mortgage => 'Mortgage', - TxCategory.transport => 'Transport', - TxCategory.dining => 'Dining', - TxCategory.shopping => 'Shopping', - TxCategory.health => 'Health', - TxCategory.transfer => 'Transfer', - TxCategory.other => 'Other', + final String id; + final String name; + final Color color; + final IconData icon; + final bool custom; + + Map toJson() => { + 'id': id, + 'name': name, + // ignore: deprecated_member_use — wide compatibility across Flutter versions + 'color': color.value, + 'custom': custom, }; - IconData get icon => switch (this) { - TxCategory.uncategorized => Icons.label_outline, - TxCategory.groceries => Icons.shopping_cart_outlined, - TxCategory.energy => Icons.bolt_outlined, - TxCategory.salary => Icons.payments_outlined, - TxCategory.mortgage => Icons.home_work_outlined, - TxCategory.transport => Icons.directions_bus_outlined, - TxCategory.dining => Icons.restaurant_outlined, - TxCategory.shopping => Icons.shopping_bag_outlined, - TxCategory.health => Icons.local_hospital_outlined, - TxCategory.transfer => Icons.swap_horiz, - TxCategory.other => Icons.more_horiz, - }; - - Color get color => switch (this) { - TxCategory.uncategorized => const Color(0xFF64748B), - TxCategory.groceries => const Color(0xFF16A34A), - TxCategory.energy => const Color(0xFFEAB308), - TxCategory.salary => const Color(0xFF0F766E), - TxCategory.mortgage => const Color(0xFF7C3AED), - TxCategory.transport => const Color(0xFF2563EB), - TxCategory.dining => const Color(0xFFEA580C), - TxCategory.shopping => const Color(0xFFDB2777), - TxCategory.health => const Color(0xFFDC2626), - TxCategory.transfer => const Color(0xFF0891B2), - TxCategory.other => const Color(0xFF57534E), - }; - - static TxCategory fromName(String? name) { - if (name == null || name.isEmpty) return TxCategory.uncategorized; - return TxCategory.values.firstWhere( - (c) => c.name == name, - orElse: () => TxCategory.uncategorized, + factory TxLabel.fromJson(Map json) { + return TxLabel( + id: json['id'] as String? ?? 'custom', + name: json['name'] as String? ?? 'Label', + color: Color(json['color'] as int? ?? 0xFF64748B), + custom: json['custom'] as bool? ?? true, ); } } +/// Built-in presets (no "uncategorized" / "other"). +const List kBuiltinLabels = [ + TxLabel( + id: 'groceries', + name: 'Groceries', + color: Color(0xFF16A34A), + icon: Icons.shopping_cart_outlined, + ), + TxLabel( + id: 'energy', + name: 'Energy', + color: Color(0xFFEAB308), + icon: Icons.bolt_outlined, + ), + TxLabel( + id: 'salary', + name: 'Salary', + color: Color(0xFF0F766E), + icon: Icons.payments_outlined, + ), + TxLabel( + id: 'mortgage', + name: 'Mortgage', + color: Color(0xFF7C3AED), + icon: Icons.home_work_outlined, + ), + TxLabel( + id: 'transport', + name: 'Transport', + color: Color(0xFF2563EB), + icon: Icons.directions_bus_outlined, + ), + TxLabel( + id: 'dining', + name: 'Dining', + color: Color(0xFFEA580C), + icon: Icons.restaurant_outlined, + ), + TxLabel( + id: 'shopping', + name: 'Shopping', + color: Color(0xFFDB2777), + icon: Icons.shopping_bag_outlined, + ), + TxLabel( + id: 'health', + name: 'Health', + color: Color(0xFFDC2626), + icon: Icons.local_hospital_outlined, + ), + TxLabel( + id: 'transfer', + name: 'Transfer', + color: Color(0xFF0891B2), + icon: Icons.swap_horiz, + ), +]; + +/// Palette for newly created custom labels. +const List kCustomLabelColors = [ + Color(0xFF0F766E), + Color(0xFF7C3AED), + Color(0xFFEA580C), + Color(0xFF2563EB), + Color(0xFFDB2777), + Color(0xFF16A34A), + Color(0xFFDC2626), + Color(0xFF57534E), +]; + /// Local overlay for a bank transaction. class TxMeta { const TxMeta({ - this.category = TxCategory.uncategorized, + this.labelIds = const [], this.recurring = false, this.notes = '', }); - final TxCategory category; + /// Zero or more label ids on this transaction. + final List labelIds; final bool recurring; final String notes; static const empty = TxMeta(); - bool get hasCategory => category != TxCategory.uncategorized; + bool get hasLabel => labelIds.isNotEmpty; bool get hasNotes => notes.trim().isNotEmpty; + /// First label id, if any (for compact UI). + String? get primaryLabelId => labelIds.isEmpty ? null : labelIds.first; + + bool hasLabelId(String id) => labelIds.contains(id); + TxMeta copyWith({ - TxCategory? category, + List? labelIds, + bool clearLabels = false, bool? recurring, String? notes, }) { return TxMeta( - category: category ?? this.category, + labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds), recurring: recurring ?? this.recurring, notes: notes ?? this.notes, ); } Map toJson() => { - 'category': category.name, + 'labelIds': labelIds, + // Legacy single-label keys for older builds. + 'labelId': primaryLabelId, + 'category': primaryLabelId, 'recurring': recurring, 'notes': notes, }; factory TxMeta.fromJson(Map json) { return TxMeta( - category: TxCategoryX.fromName(json['category'] as String?), + labelIds: _parseLabelIds(json), recurring: json['recurring'] as bool? ?? false, notes: json['notes'] as String? ?? '', ); } } +List _parseLabelIds(Map json) { + final raw = json['labelIds']; + if (raw is List) { + return [ + for (final item in raw) + if (item is String && + item.isNotEmpty && + item != 'uncategorized' && + item != 'other') + item, + ]; + } + final single = (json['labelId'] as String?) ?? + _migrateLegacyCategory(json['category'] as String?); + if (single == null) return const []; + return [single]; +} + +String? _migrateLegacyCategory(String? name) { + if (name == null || name.isEmpty) return null; + if (name == 'uncategorized' || name == 'other') return null; + return name; +} + /// Notes / recurring template keyed by exact merchant+amount match. class TxExactTemplate { const TxExactTemplate({this.notes = '', this.recurring = false}); @@ -133,3 +204,40 @@ 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+'), ' '); + if (s.startsWith('naam:')) { + s = s.substring(5).trim(); + final om = s.indexOf(' omschrijving:'); + if (om > 0) s = s.substring(0, om); + } + + const markers = [ + ' pasvolgnr', + ' kaartnr', + ' datum:', + ' tijd:', + ' transactie:', + ' term:', + ' valutadatum', + ' iban:', + ' kenmerk:', + ' machtiging', + ' overige partij', + ' retourpin', + ]; + for (final marker in markers) { + final index = s.indexOf(marker); + if (index > 6) { + s = s.substring(0, index); + } + } + + s = s + .replaceAll(RegExp(r'\s+nld\s*$'), '') + .replaceAll(RegExp(r'^bck\*'), '') + .trim(); + return s; +} diff --git a/lib/providers/bank_provider.dart b/lib/providers/bank_provider.dart index 20f9425..dee592f 100644 --- a/lib/providers/bank_provider.dart +++ b/lib/providers/bank_provider.dart @@ -5,13 +5,23 @@ import 'package:flutter/foundation.dart'; import '../models/bank.dart'; import '../services/ingtrak_api.dart'; +import '../services/storage_service.dart'; import '../utils/constants.dart'; /// App-wide banking state backed by the local ingtrak API. class BankController extends ChangeNotifier { - BankController({IngtrakApi? api}) : _api = api ?? IngtrakApi(); + BankController({IngtrakApi? api, this._storage}) + : _api = api ?? IngtrakApi() { + final cached = _storage?.loadBankCache(); + if (cached != null && cached.status.linked) { + _status = cached.status; + _balance = cached.balance; + _transactions = cached.transactions; + } + } final IngtrakApi _api; + final StorageService? _storage; ConnectStatus _status = ConnectStatus.disconnected; BankBalance? _balance; @@ -32,6 +42,22 @@ class BankController extends ChangeNotifier { String get apiBaseUrl => _api.baseUrl; String? get pendingState => _pendingState; + Future _persistCache() async { + final storage = _storage; + if (storage == null) return; + if (!_status.linked) { + await storage.clearBankCache(); + return; + } + await storage.saveBankCache( + BankCache( + status: _status, + balance: _balance, + transactions: _transactions, + ), + ); + } + /// Load status; if linked, also load balance + transactions. Future refresh() async { _isLoading = true; @@ -42,15 +68,26 @@ class BankController extends ChangeNotifier { _status = await _api.fetchStatus(); if (_status.linked) { await _loadLinkedData(); + await _persistCache(); } else { _balance = null; _transactions = const []; + await _persistCache(); } } on IngtrakApiException catch (error, stackTrace) { - _error = error.message; + // Keep cached linked data on transient API errors. + if (!isLinked || (_balance == null && _transactions.isEmpty)) { + _error = error.message; + } else { + _error = 'Using saved data · ${error.message}'; + } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } catch (error, stackTrace) { - _error = 'Could not reach $apiBaseUrl'; + if (!isLinked || (_balance == null && _transactions.isEmpty)) { + _error = 'Could not reach $apiBaseUrl'; + } else { + _error = 'Using saved data · offline/unreachable API'; + } debugPrint('BankController.refresh failed: $error\n$stackTrace'); } finally { _isLoading = false; @@ -73,8 +110,12 @@ class BankController extends ChangeNotifier { _balance = null; _transactions = const []; _error = error.message; - } else { + await _persistCache(); + } else if (_balance == null && _transactions.isEmpty) { _error = error.message; + } else { + // Keep previous balance/txs if refresh of data fails but still linked. + _error = 'Using saved data · ${error.message}'; } } } @@ -156,6 +197,7 @@ class BankController extends ChangeNotifier { _pendingState = null; if (_status.linked) { await _loadLinkedData(); + await _persistCache(); } return _status.linked; } on IngtrakApiException catch (error, stackTrace) { diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index 0c06e18..365b7ba 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -1,7 +1,7 @@ -/// Local category / notes / recurring annotations for bank transactions. +/// Local labels / notes / recurring annotations for bank transactions. library; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; @@ -11,54 +11,195 @@ class TxMetaController extends ChangeNotifier { TxMetaController(this._storage) { final loaded = _storage.loadTxMetaStore(); _byId = Map.of(loaded.byId); - _categoryByDescription = Map.of(loaded.categoryByDescription); + _labelsByMerchant = { + for (final e in loaded.labelsByMerchant.entries) + e.key: List.of(e.value), + }; + _notesByMerchant = Map.of(loaded.notesByMerchant); _exactTemplates = Map.of(loaded.exactTemplates); + _customLabels = List.of(loaded.customLabels); } final StorageService _storage; Map _byId = {}; - Map _categoryByDescription = {}; + Map> _labelsByMerchant = {}; + Map _notesByMerchant = {}; Map _exactTemplates = {}; + List _customLabels = []; - /// Resolved meta: per-id overrides + inherited category / notes rules. + /// Built-in + custom labels available for picking. + List get allLabels => [...kBuiltinLabels, ..._customLabels]; + + List get customLabels => List.unmodifiable(_customLabels); + + TxLabel? labelById(String? id) { + if (id == null || id.isEmpty) return null; + for (final label in allLabels) { + if (label.id == id) return label; + } + return null; + } + + List labelsFor(TxMeta meta) { + return [ + for (final id in meta.labelIds) + if (labelById(id) != null) labelById(id)!, + ]; + } + + /// Resolved meta: per-id overrides + inherited merchant rules. TxMeta metaFor(BankTransaction tx) { final stored = _byId[tx.id]; final template = _exactTemplates[tx.exactKey]; - final inheritedCategory = _categoryByDescription[tx.descriptionKey]; + final inheritedLabels = _labelsByMerchant[tx.merchantKey] ?? + _labelsByMerchant[tx.descriptionKey]; + final inheritedNotes = _notesByMerchant[tx.merchantKey] ?? + _notesByMerchant[tx.descriptionKey]; - final category = stored != null && stored.hasCategory - ? stored.category - : (inheritedCategory ?? TxCategory.uncategorized); + final labelIds = stored != null && stored.hasLabel + ? stored.labelIds + : (inheritedLabels ?? const []); final notes = stored != null && stored.hasNotes ? stored.notes - : (template?.notes ?? ''); + : (template != null && template.notes.trim().isNotEmpty + ? template.notes + : (inheritedNotes ?? '')); final recurring = stored?.recurring ?? template?.recurring ?? false; return TxMeta( - category: category, + labelIds: List.of(labelIds), notes: notes, recurring: recurring, ); } - Future setCategory(BankTransaction tx, TxCategory category) async { - final current = metaFor(tx); - _byId = { - ..._byId, - tx.id: current.copyWith(category: category), - }; - if (category == TxCategory.uncategorized) { - _categoryByDescription = Map.of(_categoryByDescription) - ..remove(tx.descriptionKey); - } else { - _categoryByDescription = { - ..._categoryByDescription, - tx.descriptionKey: category, - }; + int countSimilar( + BankTransaction prototype, + List all, + ) { + final key = prototype.merchantKey; + if (key.isEmpty) return 1; + return all.where((tx) => tx.merchantKey == key).length; + } + + List similarTo( + BankTransaction prototype, + List all, + ) { + final key = prototype.merchantKey; + if (key.isEmpty) return [prototype]; + return [ + for (final tx in all) + if (tx.merchantKey == key) tx, + ]; + } + + Future setLabels( + BankTransaction tx, + List labelIds, { + List applyToSimilar = const [], + }) async { + final cleaned = [ + for (final id in labelIds) + if (id.isNotEmpty) id, + ]; + // Deduplicate while preserving order. + final unique = []; + for (final id in cleaned) { + if (!unique.contains(id)) unique.add(id); } + + final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; + final nextById = Map.of(_byId); + for (final other in scope) { + if (applyToSimilar.isNotEmpty && + other.merchantKey != tx.merchantKey) { + continue; + } + final current = metaFor(other); + nextById[other.id] = unique.isEmpty + ? current.copyWith(clearLabels: true) + : current.copyWith(labelIds: unique); + } + _byId = nextById; + + final nextRules = Map>.of(_labelsByMerchant); + if (unique.isEmpty) { + nextRules.remove(tx.merchantKey); + nextRules.remove(tx.descriptionKey); + } else { + nextRules[tx.merchantKey] = unique; + } + _labelsByMerchant = nextRules; + await _persist(); + } + + Future toggleLabel( + BankTransaction tx, + String labelId, { + List applyToSimilar = const [], + }) async { + final current = List.of(metaFor(tx).labelIds); + if (current.contains(labelId)) { + current.remove(labelId); + } else { + current.add(labelId); + } + await setLabels(tx, current, applyToSimilar: applyToSimilar); + } + + Future clearLabels( + BankTransaction tx, { + List applyToSimilar = const [], + }) { + return setLabels(tx, const [], applyToSimilar: applyToSimilar); + } + + Future createLabel(String name) async { + final trimmed = name.trim(); + if (trimmed.isEmpty) { + throw ArgumentError('Label name required'); + } + final color = + kCustomLabelColors[_customLabels.length % kCustomLabelColors.length]; + final label = TxLabel( + id: 'custom_${DateTime.now().millisecondsSinceEpoch}', + name: trimmed, + color: color, + icon: Icons.label_outline, + custom: true, + ); + _customLabels = [..._customLabels, label]; + await _persist(); + return label; + } + + Future removeLabel(String labelId) async { + _customLabels = + _customLabels.where((label) => label.id != labelId).toList(); + final nextById = {}; + for (final entry in _byId.entries) { + if (entry.value.labelIds.contains(labelId)) { + final next = entry.value.labelIds.where((id) => id != labelId).toList(); + nextById[entry.key] = next.isEmpty + ? entry.value.copyWith(clearLabels: true) + : entry.value.copyWith(labelIds: next); + } else { + nextById[entry.key] = entry.value; + } + } + _byId = nextById; + _labelsByMerchant = { + for (final entry in _labelsByMerchant.entries) + if (entry.value.any((id) => id != labelId)) + entry.key: [ + for (final id in entry.value) + if (id != labelId) id, + ], + }; await _persist(); } @@ -79,24 +220,54 @@ class TxMetaController extends ChangeNotifier { await _persist(); } - Future setNotes(BankTransaction tx, String notes) async { + Future setNotes( + BankTransaction tx, + String notes, { + List applyToSimilar = const [], + }) async { final trimmed = notes.trim(); - final current = metaFor(tx); - _byId = { - ..._byId, - tx.id: current.copyWith(notes: trimmed), - }; - final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); - if (trimmed.isEmpty && !existing.recurring && !current.recurring) { - _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); + final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar; + final nextById = Map.of(_byId); + for (final other in scope) { + if (applyToSimilar.isNotEmpty && + other.merchantKey != tx.merchantKey) { + continue; + } + final current = metaFor(other); + nextById[other.id] = current.copyWith(notes: trimmed); + } + _byId = nextById; + + if (applyToSimilar.isEmpty) { + final current = metaFor(tx); + final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); + if (trimmed.isEmpty && !existing.recurring && !current.recurring) { + _exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); + } else { + _exactTemplates = { + ..._exactTemplates, + tx.exactKey: TxExactTemplate( + notes: trimmed, + recurring: existing.recurring || current.recurring, + ), + }; + } + if (trimmed.isEmpty) { + _notesByMerchant = Map.of(_notesByMerchant) + ..remove(tx.merchantKey) + ..remove(tx.descriptionKey); + } } else { - _exactTemplates = { - ..._exactTemplates, - tx.exactKey: TxExactTemplate( - notes: trimmed, - recurring: existing.recurring || current.recurring, - ), - }; + if (trimmed.isEmpty) { + _notesByMerchant = Map.of(_notesByMerchant) + ..remove(tx.merchantKey) + ..remove(tx.descriptionKey); + } else { + _notesByMerchant = { + ..._notesByMerchant, + tx.merchantKey: trimmed, + }; + } } await _persist(); } @@ -105,39 +276,43 @@ class TxMetaController extends ChangeNotifier { return setRecurring(tx, !metaFor(tx).recurring); } - /// Search description, notes, category label, and date. - /// Optional [categories] require a matching category when non-empty. + /// Search description, notes, label name, and date. List search( List txs, String query, { - Set categories = const {}, + Set labelIds = const {}, }) { final needle = query.trim().toLowerCase(); final hasText = needle.isNotEmpty; - final hasCats = categories.isNotEmpty; - if (!hasText && !hasCats) return txs; + final hasLabels = labelIds.isNotEmpty; + if (!hasText && !hasLabels) return txs; return txs.where((tx) { final meta = metaFor(tx); - if (hasCats && !categories.contains(meta.category)) return false; + if (hasLabels && !meta.labelIds.any(labelIds.contains)) { + return false; + } if (!hasText) return true; + final labelNames = labelsFor(meta).map((l) => l.name.toLowerCase()); return tx.description.toLowerCase().contains(needle) || meta.notes.toLowerCase().contains(needle) || - meta.category.label.toLowerCase().contains(needle) || + labelNames.any((name) => name.contains(needle)) || tx.date.toLowerCase().contains(needle) || tx.amount.toString().contains(needle); }).toList(growable: false); } - /// Categories currently used by [transactions] (excluding uncategorized). - List usedCategories(List transactions) { - final found = {}; + /// Labels currently used by [transactions]. + List usedLabels(List transactions) { + final found = {}; for (final tx in transactions) { - final category = metaFor(tx).category; - if (category != TxCategory.uncategorized) found.add(category); + found.addAll(metaFor(tx).labelIds); } - final list = found.toList() - ..sort((a, b) => a.label.compareTo(b.label)); + final list = [ + for (final label in allLabels) + if (found.contains(label.id)) label, + ]; + list.sort((a, b) => a.name.compareTo(b.name)); return list; } @@ -146,40 +321,62 @@ class TxMetaController extends ChangeNotifier { await _storage.saveTxMetaStore( TxMetaStoreData( byId: _byId, - categoryByDescription: _categoryByDescription, + labelsByMerchant: _labelsByMerchant, + notesByMerchant: _notesByMerchant, exactTemplates: _exactTemplates, + customLabels: _customLabels, ), ); } - /// Absolute spend (outflows only) grouped by category. - Map spendByCategory(List txs) { - final totals = {}; + /// Absolute spend (outflows) per label. Multi-label txs count fully in each. + Map spendByLabel(List txs) { + final totals = {}; for (final tx in txs) { if (tx.amount >= 0) continue; - final category = metaFor(tx).category; - totals[category] = (totals[category] ?? 0) + tx.amount.abs(); + final ids = metaFor(tx).labelIds; + if (ids.isEmpty) continue; + final amount = tx.amount.abs(); + for (final id in ids) { + totals[id] = (totals[id] ?? 0) + amount; + } } return totals; } - /// Absolute spend grouped by calendar month key `yyyy-MM`. - Map spendByMonth(List txs) { - final totals = {}; + /// Unique labeled outflow total (no double-count for multi-label txs). + double labeledSpendTotal(List txs) { + var total = 0.0; for (final tx in txs) { if (tx.amount >= 0) continue; - final key = _monthKey(tx.date); - if (key == null) continue; - totals[key] = (totals[key] ?? 0) + tx.amount.abs(); + if (!metaFor(tx).hasLabel) continue; + total += tx.amount.abs(); } - final keys = totals.keys.toList()..sort(); - return {for (final key in keys) key: totals[key]!}; + return total; } - String? _monthKey(String raw) { - final parsed = DateTime.tryParse(raw); - if (parsed == null) return null; - final m = parsed.month.toString().padLeft(2, '0'); - return '${parsed.year}-$m'; + /// Absolute spend for each day in [month] (`yyyy-MM-dd` → amount). + Map spendByDayInMonth( + List txs, + DateTime month, + ) { + final start = DateTime(month.year, month.month, 1); + final end = DateTime(month.year, month.month + 1, 0); + final totals = {}; + for (var day = 1; day <= end.day; day++) { + final key = + '${start.year}-${start.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + totals[key] = 0; + } + for (final tx in txs) { + if (tx.amount >= 0) continue; + final parsed = DateTime.tryParse(tx.date); + if (parsed == null) continue; + if (parsed.year != month.year || parsed.month != month.month) continue; + final key = + '${parsed.year}-${parsed.month.toString().padLeft(2, '0')}-${parsed.day.toString().padLeft(2, '0')}'; + totals[key] = (totals[key] ?? 0) + tx.amount.abs(); + } + return totals; } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 9ae5b9b..fa1382b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,7 +4,6 @@ library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/connect_bank.dart'; @@ -96,7 +95,7 @@ class _HomeBody extends StatelessWidget { final meta = context.watch(); final recent = bank.transactions.take(5).toList(growable: false); final categorized = bank.transactions - .where((tx) => meta.metaFor(tx).hasCategory) + .where((tx) => meta.metaFor(tx).hasLabel) .length; return ListView( @@ -207,10 +206,17 @@ class _HomeBody extends StatelessWidget { ListTile( contentPadding: EdgeInsets.zero, dense: true, - leading: Icon( - meta.metaFor(tx).category.icon, - color: meta.metaFor(tx).category.color, - ), + leading: Builder( + builder: (context) { + final labels = + meta.labelsFor(meta.metaFor(tx)); + final label = labels.isEmpty ? null : labels.first; + return Icon( + label?.icon ?? Icons.receipt_long_outlined, + color: label?.color, + ); + }, + ), title: Text( tx.description, maxLines: 1, diff --git a/lib/screens/label_transactions_screen.dart b/lib/screens/label_transactions_screen.dart new file mode 100644 index 0000000..e47b924 --- /dev/null +++ b/lib/screens/label_transactions_screen.dart @@ -0,0 +1,89 @@ +/// Transactions that share a given label. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/helpers.dart'; +import '../widgets/responsive_body.dart'; +import 'transaction_detail_screen.dart'; + +class LabelTransactionsScreen extends StatelessWidget { + const LabelTransactionsScreen({super.key, required this.label}); + + final TxLabel label; + + @override + Widget build(BuildContext context) { + final bank = context.watch(); + final meta = context.watch(); + final txs = [ + for (final tx in bank.transactions) + if (meta.metaFor(tx).labelIds.contains(label.id)) tx, + ]; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final currency = bank.balance?.currency ?? + (txs.isNotEmpty ? txs.first.currency : 'EUR'); + var totalOut = 0.0; + for (final tx in txs) { + if (tx.amount < 0) totalOut += tx.amount.abs(); + } + + return Scaffold( + appBar: AppBar( + title: Text(label.name), + ), + body: ResponsiveBody( + child: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(label.icon, color: label.color), + title: Text('${txs.length} transactions'), + subtitle: Text('Money out ${formatMoney(totalOut, currency)}'), + ), + const Divider(), + if (txs.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text( + 'No transactions with this label.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + ) + else + for (final tx in txs) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + tx.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: tx.amount >= 0 ? colors.primary : colors.error, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionDetailScreen(tx: tx), + ), + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index e0ddc4b..66b2d6a 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -15,6 +15,7 @@ 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 } @@ -29,16 +30,32 @@ 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()); - // Look ahead can go forward; still allow browsing past months. 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), + ), + ); + } + @override Widget build(BuildContext context) { final bank = context.watch(); @@ -92,9 +109,9 @@ class _OverviewScreenState extends State { ? bank.transactions.first.currency : 'EUR'); final currentBalance = bank.balance?.amount ?? 0; - final byCategory = meta.spendByCategory(bank.transactions); - final byMonth = meta.spendByMonth(bank.transactions); - final totalSpend = byCategory.values.fold(0, (sum, v) => sum + v); + 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, @@ -214,10 +231,18 @@ class _OverviewScreenState extends State { for (final row in projected.take(12)) ListTile( dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - leading: Icon( - row.item.category.icon, - color: row.item.category.color, + 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, @@ -247,7 +272,7 @@ class _OverviewScreenState extends State { ), ], const SizedBox(height: 20), - Text('Money out (categorized)', style: theme.textTheme.labelMedium), + Text('Money out (labeled)', style: theme.textTheme.labelMedium), const SizedBox(height: 4), Text( formatMoney(totalSpend, currency), @@ -258,13 +283,13 @@ class _OverviewScreenState extends State { segments: const [ ButtonSegment( value: _StatsMode.category, - label: Text('Category'), + label: Text('Label'), icon: Icon(Icons.label_outline), ), ButtonSegment( value: _StatsMode.month, - label: Text('By date'), - icon: Icon(Icons.calendar_month_outlined), + label: Text('By day'), + icon: Icon(Icons.bar_chart_outlined), ), ], selected: {_mode}, @@ -272,7 +297,33 @@ class _OverviewScreenState extends State { setState(() => _mode = selection.first); }, ), - const SizedBox(height: 20), + 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 @@ -283,21 +334,27 @@ class _OverviewScreenState extends State { ), ) : _mode == _StatsMode.category - ? _CategoryBars(data: byCategory, currency: currency) - : _MonthBars(data: byMonth, currency: currency), + ? _LabelBars( + data: byLabel, + resolve: meta.labelById, + currency: currency, + onBarTap: _openLabel, + ) + : _DayBars(data: byDay, currency: currency), ), const SizedBox(height: 20), if (_mode == _StatsMode.category) - ..._categoryLegend(context, byCategory, currency) + ..._labelLegend(context, byLabel, meta, currency) else - ..._monthLegend(context, byMonth, currency), + ..._dayLegend(context, byDay, currency), ], ); } - List _categoryLegend( + List _labelLegend( BuildContext context, - Map data, + Map data, + TxMetaController meta, String currency, ) { final entries = data.entries.toList() @@ -305,38 +362,46 @@ class _OverviewScreenState extends State { if (entries.isEmpty) { return [ Text( - 'No outflows yet. Categorize spending from the transaction list.', + 'No labeled outflows yet. Add a label from a transaction.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ for (final entry in entries) - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Icon(entry.key.icon, color: entry.key.color), - title: Text(entry.key.label), - trailing: Text(formatMoney(entry.value, currency)), + 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 _monthLegend( + List _dayLegend( BuildContext context, Map data, String currency, ) { - if (data.isEmpty) { + final spent = data.entries.where((e) => e.value > 0).toList().reversed; + if (spent.isEmpty) { return [ Text( - 'No dated outflows to chart yet.', + 'No outflows this month.', style: Theme.of(context).textTheme.bodyMedium, ), ]; } return [ - for (final entry in data.entries.toList().reversed) + for (final entry in spent) ListTile( contentPadding: EdgeInsets.zero, dense: true, @@ -348,20 +413,31 @@ class _OverviewScreenState extends State { } } -class _CategoryBars extends StatelessWidget { - const _CategoryBars({required this.data, required this.currency}); +class _LabelBars extends StatelessWidget { + const _LabelBars({ + required this.data, + required this.resolve, + required this.currency, + required this.onBarTap, + }); - final Map data; + final Map data; + final TxLabel? Function(String? id) resolve; final String currency; + final void Function(TxLabel label) onBarTap; @override Widget build(BuildContext context) { - final entries = data.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); + 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( - 'Categorize transactions to see this chart', + 'Label transactions to see this chart', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), @@ -373,11 +449,19 @@ class _CategoryBars extends StatelessWidget { 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.key.label}\n${formatMoney(entry.value, currency)}', + '${entry.label.name}\n${formatMoney(entry.value, currency)}', TextStyle( color: Theme.of(context).colorScheme.onInverseSurface, ), @@ -399,7 +483,7 @@ class _CategoryBars extends StatelessWidget { if (index < 0 || index >= entries.length) { return const SizedBox.shrink(); } - final label = entries[index].key.label; + final label = entries[index].label.name; return Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -423,7 +507,7 @@ class _CategoryBars extends StatelessWidget { width: 18, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), - color: entries[i].key.color, + color: entries[i].label.color, ), ], ), @@ -433,8 +517,8 @@ class _CategoryBars extends StatelessWidget { } } -class _MonthBars extends StatelessWidget { - const _MonthBars({required this.data, required this.currency}); +class _DayBars extends StatelessWidget { + const _DayBars({required this.data, required this.currency}); final Map data; final String currency; @@ -442,26 +526,30 @@ class _MonthBars extends StatelessWidget { @override Widget build(BuildContext context) { final entries = data.entries.toList(); - if (entries.isEmpty) { + if (entries.every((e) => e.value == 0)) { return Center( child: Text( - 'No monthly spend yet', + 'No spend this month', style: Theme.of(context).textTheme.bodyMedium, ), ); } - final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b); + 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: maxY * 1.15, + 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( - '${entry.key}\n${formatMoney(entry.value, currency)}', + 'Day $day\n${formatMoney(entry.value, currency)}', TextStyle(color: colors.onInverseSurface), ); }, @@ -476,17 +564,20 @@ class _MonthBars extends StatelessWidget { 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(); } - final key = entries[index].key; - final short = key.length >= 7 ? key.substring(5) : key; + 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( - short, + day, style: Theme.of(context).textTheme.labelSmall, ), ); @@ -503,10 +594,12 @@ class _MonthBars extends StatelessWidget { barRods: [ BarChartRodData( toY: entries[i].value, - width: 22, + width: entries.length > 20 ? 6 : 8, borderRadius: - const BorderRadius.vertical(top: Radius.circular(6)), - color: colors.primary, + const BorderRadius.vertical(top: Radius.circular(3)), + color: entries[i].value > 0 + ? colors.primary + : colors.surfaceContainerHighest, ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4c8e7be..39762ec 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -92,9 +92,9 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), - const ListTile( - leading: Icon(Icons.open_in_browser_outlined), - title: Text('Enable Banking redirect'), + ListTile( + leading: const Icon(Icons.open_in_browser_outlined), + title: const Text('Enable Banking redirect'), subtitle: Text(kEnableBankingRedirectUri), ), ], @@ -104,9 +104,8 @@ class SettingsScreen extends StatelessWidget { Text( 'Enable Banking only allows http(s) redirects. Keep ' '$kEnableBankingRedirectUri in the Control Panel and backend ' - 'REDIRECT_URI. The app captures that URL inside a WebView. ' - 'Override the API host with ' - '`--dart-define=API_BASE_URL=http://:8000`.', + 'REDIRECT_URI. The app captures that callback path in a WebView. ' + 'Override with `--dart-define=API_BASE_URL=https://…`.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 28), diff --git a/lib/screens/transaction_detail_screen.dart b/lib/screens/transaction_detail_screen.dart index 1e1eecf..f717246 100644 --- a/lib/screens/transaction_detail_screen.dart +++ b/lib/screens/transaction_detail_screen.dart @@ -1,4 +1,4 @@ -/// Transaction detail: category, recurring, and user notes. +/// Transaction detail: label, recurring, notes, apply-to-similar. library; import 'package:flutter/material.dart'; @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/helpers.dart'; import '../widgets/responsive_body.dart'; @@ -23,12 +24,18 @@ class TransactionDetailScreen extends StatefulWidget { class _TransactionDetailScreenState extends State { late final TextEditingController _notesController; var _dirty = false; + late bool _applyToSimilar; @override void initState() { super.initState(); - final notes = context.read().metaFor(widget.tx).notes; + final meta = context.read(); + final notes = meta.metaFor(widget.tx).notes; _notesController = TextEditingController(text: notes); + final all = context.read().transactions; + final similarCount = meta.countSimilar(widget.tx, all); + // Default on when there are peers — otherwise users never notice the toggle. + _applyToSimilar = similarCount > 1; } @override @@ -37,25 +44,116 @@ class _TransactionDetailScreenState extends State { super.dispose(); } + List _similarScope(TxMetaController meta) { + final all = context.read().transactions; + if (!_applyToSimilar) return const []; + return meta.similarTo(widget.tx, all); + } + Future _saveNotes() async { - await context.read().setNotes( - widget.tx, - _notesController.text, - ); + final meta = context.read(); + final similar = _similarScope(meta); + await meta.setNotes( + widget.tx, + _notesController.text, + applyToSimilar: similar, + ); if (!mounted) return; setState(() => _dirty = false); + final count = similar.isEmpty ? 1 : similar.length; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Notes saved')), + SnackBar( + content: Text( + count == 1 + ? 'Notes saved' + : 'Notes saved on $count similar transactions', + ), + ), ); } + Future _toggleLabel(String labelId) async { + final meta = context.read(); + final similar = _similarScope(meta); + await meta.toggleLabel( + widget.tx, + labelId, + applyToSimilar: similar, + ); + if (!mounted) return; + final count = similar.isEmpty ? 1 : similar.length; + if (count > 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Labels updated on $count similar transactions'), + ), + ); + } + } + + Future _clearLabels() async { + final meta = context.read(); + final similar = _similarScope(meta); + await meta.clearLabels( + widget.tx, + applyToSimilar: similar, + ); + } + + Future _createLabel() async { + final name = await showDialog( + context: context, + builder: (context) => const _NewLabelDialog(), + ); + if (name == null || name.isEmpty || !mounted) return; + final meta = context.read(); + final label = await meta.createLabel(name); + if (!mounted) return; + await _toggleLabel(label.id); + } + + Future _confirmRemoveLabel(TxLabel label) async { + final ok = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Remove “${label.name}”?'), + content: const Text( + 'This deletes the label. Transactions keep any other labels.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ); + }, + ); + if (ok != true || !mounted) return; + await context.read().removeLabel(label.id); + } + @override Widget build(BuildContext context) { final tx = widget.tx; - final meta = context.watch().metaFor(tx); + final metaController = context.watch(); + final meta = metaController.metaFor(tx); + final all = context.watch().transactions; + final similarCount = metaController.countSimilar(tx, all); final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; + final selectedIds = meta.labelIds.toSet(); + final merchantPreview = tx.merchantKey.isEmpty + ? tx.description + : (tx.merchantKey.length > 48 + ? '${tx.merchantKey.substring(0, 47)}…' + : tx.merchantKey); return Scaffold( appBar: AppBar( @@ -86,7 +184,20 @@ class _TransactionDetailScreenState extends State { ].join(' · '), style: theme.textTheme.bodyMedium, ), - const SizedBox(height: 24), + if (similarCount > 1) ...[ + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.copy_all_outlined), + title: const Text('Apply to all similar'), + subtitle: Text( + '$similarCount matches · $merchantPreview', + ), + value: _applyToSimilar, + onChanged: (value) => setState(() => _applyToSimilar = value), + ), + ], + const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, secondary: const Icon(Icons.autorenew), @@ -98,25 +209,39 @@ class _TransactionDetailScreenState extends State { }, ), const SizedBox(height: 16), - Text('Category', style: theme.textTheme.titleSmall), + Row( + children: [ + Text('Labels', style: theme.textTheme.titleSmall), + const Spacer(), + if (meta.hasLabel) + TextButton( + onPressed: _clearLabels, + child: const Text('Clear'), + ), + ], + ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ - for (final category in TxCategory.values) - ChoiceChip( - avatar: Icon( - category.icon, - size: 16, - color: category.color, + for (final label in metaController.allLabels) + GestureDetector( + onLongPress: label.custom + ? () => _confirmRemoveLabel(label) + : null, + child: FilterChip( + avatar: Icon(label.icon, size: 16, color: label.color), + label: Text(label.name), + selected: selectedIds.contains(label.id), + onSelected: (_) => _toggleLabel(label.id), ), - label: Text(category.label), - selected: meta.category == category, - onSelected: (_) { - context.read().setCategory(tx, category); - }, ), + ActionChip( + avatar: const Icon(Icons.add, size: 16), + label: const Text('New label'), + onPressed: _createLabel, + ), ], ), const SizedBox(height: 24), @@ -137,12 +262,17 @@ class _TransactionDetailScreenState extends State { const SizedBox(height: 12), FilledButton( onPressed: _dirty ? _saveNotes : null, - child: const Text('Save notes'), + child: Text( + _applyToSimilar && similarCount > 1 + ? 'Save notes for $similarCount' + : 'Save notes', + ), ), const SizedBox(height: 16), Text( - 'Matching descriptions reuse this category automatically. ' - 'Exact same amount + description also copies notes.', + 'Tap labels to add or remove several. Long-press a custom label ' + 'to delete it. Similar matches strip dates and ids so supermarket ' + 'purchases group together.', style: theme.textTheme.labelMedium, ), ], @@ -151,3 +281,54 @@ class _TransactionDetailScreenState extends State { ); } } + +class _NewLabelDialog extends StatefulWidget { + const _NewLabelDialog(); + + @override + State<_NewLabelDialog> createState() => _NewLabelDialogState(); +} + +class _NewLabelDialogState extends State<_NewLabelDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + Navigator.pop(context, _controller.text.trim()); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('New label'), + content: TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(hintText: 'e.g. Supermarket'), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Create'), + ), + ], + ); + } +} diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 80149a5..23d7483 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -31,7 +31,7 @@ class _TransactionsScreenState extends State { DateTime _month = monthStart(DateTime.now()); DateTime? _selectedDay; var _searchInMonthOnly = false; - final Set _categoryFilters = {}; + final Set _labelFilters = {}; @override void dispose() { @@ -40,7 +40,7 @@ class _TransactionsScreenState extends State { } bool get _isSearching => - _query.trim().isNotEmpty || _categoryFilters.isNotEmpty; + _query.trim().isNotEmpty || _labelFilters.isNotEmpty; void _goMonth(int delta) { final next = addMonths(_month, delta); @@ -67,15 +67,15 @@ class _TransactionsScreenState extends State { return isSameMonth(date, _month); } - Future _pickCategories(List available) async { + Future _pickLabels(List available) async { if (available.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No categories in use yet')), + const SnackBar(content: Text('No labels in use yet')), ); return; } - final selected = Set.of(_categoryFilters); + final selected = Set.of(_labelFilters); await showModalBottomSheet( context: context, showDragHandle: true, @@ -90,7 +90,7 @@ class _TransactionsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - 'Filter by category', + 'Filter by label', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), @@ -98,21 +98,21 @@ class _TransactionsScreenState extends State { spacing: 8, runSpacing: 8, children: [ - for (final category in available) + for (final label in available) FilterChip( avatar: Icon( - category.icon, + label.icon, size: 16, - color: category.color, + color: label.color, ), - label: Text(category.label), - selected: selected.contains(category), + label: Text(label.name), + selected: selected.contains(label.id), onSelected: (value) { setSheetState(() { if (value) { - selected.add(category); + selected.add(label.id); } else { - selected.remove(category); + selected.remove(label.id); } }); }, @@ -123,7 +123,7 @@ class _TransactionsScreenState extends State { FilledButton( onPressed: () { setState(() { - _categoryFilters + _labelFilters ..clear() ..addAll(selected); }); @@ -241,7 +241,7 @@ class _TransactionsScreenState extends State { final filtered = metaController.search( searchPool, _query, - categories: _categoryFilters, + labelIds: _labelFilters, ); var moneyOut = 0.0; @@ -257,7 +257,10 @@ class _TransactionsScreenState extends State { (bank.transactions.isNotEmpty ? bank.transactions.first.currency : 'EUR'); - final usedCategories = metaController.usedCategories(bank.transactions); + final usedLabels = metaController.usedLabels(bank.transactions); + final labelById = { + for (final label in metaController.allLabels) label.id: label, + }; return ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -274,13 +277,11 @@ class _TransactionsScreenState extends State { decoration: InputDecoration( hintText: 'Search description, notes…', prefixIcon: IconButton( - tooltip: 'Categories', - onPressed: () => _pickCategories(usedCategories), + tooltip: 'Labels', + onPressed: () => _pickLabels(usedLabels), icon: Icon( Icons.search, - color: _categoryFilters.isEmpty - ? null - : colors.primary, + color: _labelFilters.isEmpty ? null : colors.primary, ), ), suffixIcon: _query.isEmpty @@ -316,21 +317,26 @@ class _TransactionsScreenState extends State { ], ), ), - if (_categoryFilters.isNotEmpty) + if (_labelFilters.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 8), child: Wrap( spacing: 8, runSpacing: 8, children: [ - for (final category in _categoryFilters) - InputChip( - avatar: Icon(category.icon, size: 16, color: category.color), - label: Text(category.label), - onDeleted: () { - setState(() => _categoryFilters.remove(category)); - }, - ), + for (final id in _labelFilters) + if (labelById[id] != null) + InputChip( + avatar: Icon( + labelById[id]!.icon, + size: 16, + color: labelById[id]!.color, + ), + label: Text(labelById[id]!.name), + onDeleted: () { + setState(() => _labelFilters.remove(id)); + }, + ), ], ), ), @@ -411,6 +417,8 @@ class _TransactionsScreenState extends State { _CompactTransactionTile( tx: filtered[i], meta: metaController.metaFor(filtered[i]), + labels: metaController + .labelsFor(metaController.metaFor(filtered[i])), ), ], ], @@ -467,16 +475,21 @@ class _CompactTransactionTile extends StatelessWidget { const _CompactTransactionTile({ required this.tx, required this.meta, + this.labels = const [], }); final BankTransaction tx; final TxMeta meta; + final List labels; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final amountColor = tx.amount >= 0 ? colors.primary : colors.error; + final primary = labels.isEmpty ? null : labels.first; + final iconColor = primary?.color ?? colors.outline; + final iconData = primary?.icon ?? Icons.receipt_long_outlined; return InkWell( onTap: () { @@ -494,13 +507,13 @@ class _CompactTransactionTile extends StatelessWidget { width: 36, height: 36, decoration: BoxDecoration( - color: meta.category.color.withValues(alpha: 0.14), + color: iconColor.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(10), ), child: Icon( - meta.category.icon, + iconData, size: 18, - color: meta.category.color, + color: iconColor, ), ), const SizedBox(width: 12), @@ -521,7 +534,8 @@ class _CompactTransactionTile extends StatelessWidget { Text( [ formatIsoDate(tx.date), - if (meta.hasCategory) meta.category.label, + if (labels.isNotEmpty) + labels.map((l) => l.name).join(', '), if (meta.recurring) 'Recurring', if (meta.hasNotes) 'Note', if (tx.pending) 'Pending', diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 456feb6..bf13abf 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../utils/constants.dart'; @@ -12,13 +13,81 @@ import '../utils/constants.dart'; class TxMetaStoreData { const TxMetaStoreData({ this.byId = const {}, - this.categoryByDescription = const {}, + this.labelsByMerchant = const {}, + this.notesByMerchant = const {}, this.exactTemplates = const {}, + this.customLabels = const [], }); final Map byId; - final Map categoryByDescription; + final Map> labelsByMerchant; + final Map notesByMerchant; final Map exactTemplates; + final List customLabels; +} + +/// Cached bank payload so the app stays usable across restarts / brief outages. +class BankCache { + const BankCache({ + required this.status, + this.balance, + this.transactions = const [], + }); + + final ConnectStatus status; + final BankBalance? balance; + final List transactions; + + Map toJson() => { + 'status': { + 'status': status.status, + 'linked': status.linked, + 'accountId': status.accountId, + 'sessionId': status.sessionId, + 'state': status.state, + 'validUntil': status.validUntil, + }, + 'balance': balance == null + ? null + : { + 'amount': balance!.amount, + 'currency': balance!.currency, + 'balanceType': balance!.balanceType, + }, + 'transactions': [ + for (final tx in transactions) + { + 'date': tx.date, + 'description': tx.description, + 'amount': tx.amount, + 'currency': tx.currency, + 'pending': tx.pending, + }, + ], + }; + + factory BankCache.fromJson(Map json) { + final statusRaw = json['status']; + final status = statusRaw is Map + ? ConnectStatus.fromJson(Map.from(statusRaw)) + : ConnectStatus.disconnected; + final balanceRaw = json['balance']; + final balance = balanceRaw is Map + ? BankBalance.fromJson(Map.from(balanceRaw)) + : null; + final txsRaw = json['transactions']; + final transactions = txsRaw is List + ? txsRaw + .whereType() + .map((e) => BankTransaction.fromJson(Map.from(e))) + .toList(growable: false) + : const []; + return BankCache( + status: status, + balance: balance, + transactions: transactions, + ); + } } /// Loads and stores user preferences. Call [init] once at startup. @@ -52,34 +121,54 @@ class StorageService { return _prefs.setBool(StorageKeys.useRemoteData, value); } + BankCache? loadBankCache() { + final raw = _prefs.getString(StorageKeys.bankCache); + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return BankCache.fromJson(Map.from(decoded)); + } catch (_) { + return null; + } + } + + Future saveBankCache(BankCache cache) { + return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson())); + } + + Future clearBankCache() { + return _prefs.remove(StorageKeys.bankCache); + } + TxMetaStoreData loadTxMetaStore() { final raw = _prefs.getString(StorageKeys.txMeta); if (raw != null && raw.isNotEmpty) { return _decodeStore(raw); } - // Migrate flat v1 map → v2 store. final legacy = _prefs.getString(StorageKeys.txMetaLegacy); if (legacy == null || legacy.isEmpty) return const TxMetaStoreData(); try { final decoded = jsonDecode(legacy); if (decoded is! Map) return const TxMetaStoreData(); final byId = {}; - final categoryByDescription = {}; + final labelsByMerchant = >{}; decoded.forEach((key, value) { if (key is! String || value is! Map) return; final meta = TxMeta.fromJson(Map.from(value)); byId[key] = meta; - if (meta.hasCategory) { + if (meta.hasLabel) { final desc = _descriptionFromLegacyId(key); if (desc != null) { - categoryByDescription[desc] = meta.category; + labelsByMerchant[merchantKeyFromDescription(desc)] = + List.of(meta.labelIds); } } }); return TxMetaStoreData( byId: byId, - categoryByDescription: categoryByDescription, + labelsByMerchant: labelsByMerchant, ); } catch (_) { return const TxMetaStoreData(); @@ -91,31 +180,30 @@ class StorageService { 'byId': { for (final e in store.byId.entries) e.key: e.value.toJson(), }, - 'categoryByDescription': { - for (final e in store.categoryByDescription.entries) - e.key: e.value.name, + 'labelsByMerchant': { + for (final e in store.labelsByMerchant.entries) e.key: e.value, }, + // Legacy single-label map for older builds. + 'labelByMerchant': { + for (final e in store.labelsByMerchant.entries) + if (e.value.isNotEmpty) e.key: e.value.first, + }, + 'categoryByDescription': { + for (final e in store.labelsByMerchant.entries) + if (e.value.isNotEmpty) e.key: e.value.first, + }, + 'notesByMerchant': store.notesByMerchant, + 'notesByDescription': store.notesByMerchant, 'exactTemplates': { for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), }, + 'customLabels': [ + for (final label in store.customLabels) label.toJson(), + ], }; return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded)); } - /// Deprecated shim used by older call sites during transition. - Map loadTxMeta() => loadTxMetaStore().byId; - - Future saveTxMeta(Map meta) { - final current = loadTxMetaStore(); - return saveTxMetaStore( - TxMetaStoreData( - byId: meta, - categoryByDescription: current.categoryByDescription, - exactTemplates: current.exactTemplates, - ), - ); - } - TxMetaStoreData _decodeStore(String raw) { try { final decoded = jsonDecode(raw); @@ -132,12 +220,46 @@ class StorageService { }); } - final categoryByDescription = {}; - final cats = root['categoryByDescription']; - if (cats is Map) { - cats.forEach((key, value) { - if (key is String && value is String) { - categoryByDescription[key] = TxCategoryX.fromName(value); + final labelsByMerchant = >{}; + final multi = root['labelsByMerchant']; + if (multi is Map) { + multi.forEach((key, value) { + if (key is! String) return; + if (value is List) { + final ids = [ + for (final item in value) + if (item is String && + item.isNotEmpty && + item != 'uncategorized' && + item != 'other') + item, + ]; + if (ids.isNotEmpty) labelsByMerchant[key] = ids; + } else if (value is String && + value.isNotEmpty && + value != 'uncategorized' && + value != 'other') { + labelsByMerchant[key] = [value]; + } + }); + } else { + final singles = + root['labelByMerchant'] ?? root['categoryByDescription']; + if (singles is Map) { + singles.forEach((key, value) { + if (key is! String || value is! String) return; + if (value == 'uncategorized' || value == 'other') return; + labelsByMerchant[key] = [value]; + }); + } + } + + final notesByMerchant = {}; + final notesRaw = root['notesByMerchant'] ?? root['notesByDescription']; + if (notesRaw is Map) { + notesRaw.forEach((key, value) { + if (key is String && value is String && value.trim().isNotEmpty) { + notesByMerchant[key] = value; } }); } @@ -153,10 +275,24 @@ class StorageService { }); } + final customLabels = []; + final customs = root['customLabels']; + if (customs is List) { + for (final item in customs) { + if (item is Map) { + customLabels.add( + TxLabel.fromJson(Map.from(item)), + ); + } + } + } + return TxMetaStoreData( byId: byId, - categoryByDescription: categoryByDescription, + labelsByMerchant: labelsByMerchant, + notesByMerchant: notesByMerchant, exactTemplates: exactTemplates, + customLabels: customLabels, ); } catch (_) { return const TxMetaStoreData(); diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 16c7a2b..f6b4416 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -13,23 +13,25 @@ const Duration kSplashDuration = Duration(seconds: 2); /// Base URL for the personal ingtrak FastAPI backend. /// /// Override at build/run time: -/// `flutter run --dart-define=API_BASE_URL=http://192.168.1.114:8000` +/// `flutter run --dart-define=API_BASE_URL=https://ingtrak.satoshinakamoto.win` /// -/// Defaults: -/// - Android emulator → `http://10.0.2.2:8000` (host loopback) -/// - iOS Simulator → `http://127.0.0.1:8000` -/// - Physical device → Mac LAN IP, e.g. `http://192.168.1.114:8000` +/// Local defaults (when override omitted): +/// - Android emulator → `http://10.0.2.2:8000` +/// - Physical device on LAN → `http://192.168.x.x:8000` const String kApiBaseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.0.2.2:8000', + defaultValue: 'https://ingtrak.satoshinakamoto.win', ); -/// Enable Banking only accepts http(s) redirect URLs (not custom schemes). -/// Keep this exact value in the Control Panel and backend `REDIRECT_URI`. -/// The app opens auth in a WebView and intercepts this callback to read `code`. -const String kEnableBankingRedirectUri = - 'http://localhost:8000/api/connect/callback'; - +/// Enable Banking redirect derived from [kApiBaseUrl]. +/// Must match backend `REDIRECT_URI` and the Control Panel whitelist. +/// The app opens auth in a WebView and intercepts this callback path for `code`. +String get kEnableBankingRedirectUri { + final base = kApiBaseUrl.endsWith('/') + ? kApiBaseUrl.substring(0, kApiBaseUrl.length - 1) + : kApiBaseUrl; + return '$base/api/connect/callback'; +} /// Optional app-scheme bounce used after the http callback (not registered /// with Enable Banking). const String kBankRedirectScheme = 'ingtrak'; @@ -42,6 +44,7 @@ abstract final class StorageKeys { static const useRemoteData = 'use_remote_data'; static const txMeta = 'tx_meta_v2'; static const txMetaLegacy = 'tx_meta_v1'; + static const bankCache = 'bank_cache_v1'; } /// Named routes registered on [MaterialApp]. diff --git a/lib/utils/look_ahead.dart b/lib/utils/look_ahead.dart index 4e2833e..a0ea2f3 100644 --- a/lib/utils/look_ahead.dart +++ b/lib/utils/look_ahead.dart @@ -12,14 +12,14 @@ class RecurringItem { required this.amount, required this.currency, required this.dayOfMonth, - required this.category, + this.labelIds = const [], }); final String description; final double amount; final String currency; final int dayOfMonth; - final TxCategory category; + final List labelIds; } /// Build unique recurring templates from annotated transactions. @@ -51,7 +51,7 @@ List recurringItemsFrom({ amount: tx.amount, currency: tx.currency, dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), - category: metaFor(tx).category, + labelIds: List.of(metaFor(tx).labelIds), ), ]; } diff --git a/test/widget_test.dart b/test/widget_test.dart index f86ccc7..9463da2 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -41,19 +41,21 @@ void main() { expect(start.state, 'abc'); }); - test('BankTransaction match keys support auto-category and notes', () { + test('BankTransaction match keys support auto-label and notes', () { final tx = BankTransaction.fromJson({ 'date': '2026-09-01', - 'description': 'Coffee Shop', + 'description': + 'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: C00338123', 'amount': -3.5, 'currency': 'EUR', 'pending': false, }); - expect(tx.descriptionKey, 'coffee shop'); - expect(tx.exactKey, contains('coffee shop')); + expect(tx.descriptionKey, contains('pasvolgnr')); + expect(tx.merchantKey, contains('ah campus diemen')); + expect(tx.merchantKey.contains('pasvolgnr'), isFalse); expect(tx.exactKey, contains('-3.5')); expect(formatMoney(tx.amount, tx.currency), contains('EUR')); - expect(TxCategory.groceries.label, 'Groceries'); + expect(kBuiltinLabels.first.name, 'Groceries'); }); test('look-ahead applies monthly recurring amounts', () { @@ -63,14 +65,14 @@ void main() { amount: 2000, currency: 'EUR', dayOfMonth: 25, - category: TxCategory.salary, + labelIds: ['salary'], ), const RecurringItem( description: 'Rent', amount: -900, currency: 'EUR', dayOfMonth: 1, - category: TxCategory.mortgage, + labelIds: ['mortgage'], ), ]; final from = DateTime(2026, 9, 6);