ing connected, makign some features

This commit is contained in:
davmar
2026-09-07 00:20:58 +02:00
parent 87afe271b7
commit 21df7b0085
15 changed files with 1197 additions and 322 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ class _MyAppState extends State<MyApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_bank = BankController()..refresh(); _bank = BankController(storage: widget.storage)..refresh();
_appLinks = AppLinks(); _appLinks = AppLinks();
_listenForBankRedirects(); _listenForBankRedirects();
} }
+5
View File
@@ -1,6 +1,8 @@
/// Domain models for the personal ingtrak bank-data API. /// Domain models for the personal ingtrak bank-data API.
library; library;
import 'transaction_meta.dart' show merchantKeyFromDescription;
/// Result of `POST /api/connect`. /// Result of `POST /api/connect`.
class ConnectStart { class ConnectStart {
const ConnectStart({required this.authUrl, required this.state}); const ConnectStart({required this.authUrl, required this.state});
@@ -101,6 +103,9 @@ class BankTransaction {
String get descriptionKey => String get descriptionKey =>
description.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); 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). /// Exact match for copying notes (same description + amount + currency).
String get exactKey => '$descriptionKey|$amount|$currency'; String get exactKey => '$descriptionKey|$amount|$currency';
+176 -68
View File
@@ -1,119 +1,190 @@
/// Local transaction categories and per-transaction annotations. /// Transaction labels (built-in + user-created) and local annotations.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// User-facing spend / income categories (stored locally, not on the bank API). /// A spend/income label shown on transactions.
enum TxCategory { class TxLabel {
uncategorized, const TxLabel({
groceries, required this.id,
energy, required this.name,
salary, required this.color,
mortgage, this.icon = Icons.label_outline,
transport, this.custom = false,
dining, });
shopping,
health,
transfer,
other,
}
extension TxCategoryX on TxCategory { final String id;
String get label => switch (this) { final String name;
TxCategory.uncategorized => 'Uncategorized', final Color color;
TxCategory.groceries => 'Groceries', final IconData icon;
TxCategory.energy => 'Energy', final bool custom;
TxCategory.salary => 'Salary',
TxCategory.mortgage => 'Mortgage', Map<String, dynamic> toJson() => {
TxCategory.transport => 'Transport', 'id': id,
TxCategory.dining => 'Dining', 'name': name,
TxCategory.shopping => 'Shopping', // ignore: deprecated_member_use — wide compatibility across Flutter versions
TxCategory.health => 'Health', 'color': color.value,
TxCategory.transfer => 'Transfer', 'custom': custom,
TxCategory.other => 'Other',
}; };
IconData get icon => switch (this) { factory TxLabel.fromJson(Map<String, dynamic> json) {
TxCategory.uncategorized => Icons.label_outline, return TxLabel(
TxCategory.groceries => Icons.shopping_cart_outlined, id: json['id'] as String? ?? 'custom',
TxCategory.energy => Icons.bolt_outlined, name: json['name'] as String? ?? 'Label',
TxCategory.salary => Icons.payments_outlined, color: Color(json['color'] as int? ?? 0xFF64748B),
TxCategory.mortgage => Icons.home_work_outlined, custom: json['custom'] as bool? ?? true,
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,
); );
} }
} }
/// Built-in presets (no "uncategorized" / "other").
const List<TxLabel> 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<Color> kCustomLabelColors = [
Color(0xFF0F766E),
Color(0xFF7C3AED),
Color(0xFFEA580C),
Color(0xFF2563EB),
Color(0xFFDB2777),
Color(0xFF16A34A),
Color(0xFFDC2626),
Color(0xFF57534E),
];
/// Local overlay for a bank transaction. /// Local overlay for a bank transaction.
class TxMeta { class TxMeta {
const TxMeta({ const TxMeta({
this.category = TxCategory.uncategorized, this.labelIds = const [],
this.recurring = false, this.recurring = false,
this.notes = '', this.notes = '',
}); });
final TxCategory category; /// Zero or more label ids on this transaction.
final List<String> labelIds;
final bool recurring; final bool recurring;
final String notes; final String notes;
static const empty = TxMeta(); static const empty = TxMeta();
bool get hasCategory => category != TxCategory.uncategorized; bool get hasLabel => labelIds.isNotEmpty;
bool get hasNotes => notes.trim().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({ TxMeta copyWith({
TxCategory? category, List<String>? labelIds,
bool clearLabels = false,
bool? recurring, bool? recurring,
String? notes, String? notes,
}) { }) {
return TxMeta( return TxMeta(
category: category ?? this.category, labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds),
recurring: recurring ?? this.recurring, recurring: recurring ?? this.recurring,
notes: notes ?? this.notes, notes: notes ?? this.notes,
); );
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'category': category.name, 'labelIds': labelIds,
// Legacy single-label keys for older builds.
'labelId': primaryLabelId,
'category': primaryLabelId,
'recurring': recurring, 'recurring': recurring,
'notes': notes, 'notes': notes,
}; };
factory TxMeta.fromJson(Map<String, dynamic> json) { factory TxMeta.fromJson(Map<String, dynamic> json) {
return TxMeta( return TxMeta(
category: TxCategoryX.fromName(json['category'] as String?), labelIds: _parseLabelIds(json),
recurring: json['recurring'] as bool? ?? false, recurring: json['recurring'] as bool? ?? false,
notes: json['notes'] as String? ?? '', notes: json['notes'] as String? ?? '',
); );
} }
} }
List<String> _parseLabelIds(Map<String, dynamic> 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. /// Notes / recurring template keyed by exact merchant+amount match.
class TxExactTemplate { class TxExactTemplate {
const TxExactTemplate({this.notes = '', this.recurring = false}); 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;
}
+46 -4
View File
@@ -5,13 +5,23 @@ import 'package:flutter/foundation.dart';
import '../models/bank.dart'; import '../models/bank.dart';
import '../services/ingtrak_api.dart'; import '../services/ingtrak_api.dart';
import '../services/storage_service.dart';
import '../utils/constants.dart'; import '../utils/constants.dart';
/// App-wide banking state backed by the local ingtrak API. /// App-wide banking state backed by the local ingtrak API.
class BankController extends ChangeNotifier { 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 IngtrakApi _api;
final StorageService? _storage;
ConnectStatus _status = ConnectStatus.disconnected; ConnectStatus _status = ConnectStatus.disconnected;
BankBalance? _balance; BankBalance? _balance;
@@ -32,6 +42,22 @@ class BankController extends ChangeNotifier {
String get apiBaseUrl => _api.baseUrl; String get apiBaseUrl => _api.baseUrl;
String? get pendingState => _pendingState; String? get pendingState => _pendingState;
Future<void> _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. /// Load status; if linked, also load balance + transactions.
Future<void> refresh() async { Future<void> refresh() async {
_isLoading = true; _isLoading = true;
@@ -42,15 +68,26 @@ class BankController extends ChangeNotifier {
_status = await _api.fetchStatus(); _status = await _api.fetchStatus();
if (_status.linked) { if (_status.linked) {
await _loadLinkedData(); await _loadLinkedData();
await _persistCache();
} else { } else {
_balance = null; _balance = null;
_transactions = const []; _transactions = const [];
await _persistCache();
} }
} on IngtrakApiException catch (error, stackTrace) { } 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'); debugPrint('BankController.refresh failed: $error\n$stackTrace');
} catch (error, 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'); debugPrint('BankController.refresh failed: $error\n$stackTrace');
} finally { } finally {
_isLoading = false; _isLoading = false;
@@ -73,8 +110,12 @@ class BankController extends ChangeNotifier {
_balance = null; _balance = null;
_transactions = const []; _transactions = const [];
_error = error.message; _error = error.message;
} else { await _persistCache();
} else if (_balance == null && _transactions.isEmpty) {
_error = error.message; _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; _pendingState = null;
if (_status.linked) { if (_status.linked) {
await _loadLinkedData(); await _loadLinkedData();
await _persistCache();
} }
return _status.linked; return _status.linked;
} on IngtrakApiException catch (error, stackTrace) { } on IngtrakApiException catch (error, stackTrace) {
+271 -74
View File
@@ -1,7 +1,7 @@
/// Local category / notes / recurring annotations for bank transactions. /// Local labels / notes / recurring annotations for bank transactions.
library; library;
import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart';
import '../models/bank.dart'; import '../models/bank.dart';
import '../models/transaction_meta.dart'; import '../models/transaction_meta.dart';
@@ -11,54 +11,195 @@ class TxMetaController extends ChangeNotifier {
TxMetaController(this._storage) { TxMetaController(this._storage) {
final loaded = _storage.loadTxMetaStore(); final loaded = _storage.loadTxMetaStore();
_byId = Map.of(loaded.byId); _byId = Map.of(loaded.byId);
_categoryByDescription = Map.of(loaded.categoryByDescription); _labelsByMerchant = {
for (final e in loaded.labelsByMerchant.entries)
e.key: List<String>.of(e.value),
};
_notesByMerchant = Map.of(loaded.notesByMerchant);
_exactTemplates = Map.of(loaded.exactTemplates); _exactTemplates = Map.of(loaded.exactTemplates);
_customLabels = List.of(loaded.customLabels);
} }
final StorageService _storage; final StorageService _storage;
Map<String, TxMeta> _byId = {}; Map<String, TxMeta> _byId = {};
Map<String, TxCategory> _categoryByDescription = {}; Map<String, List<String>> _labelsByMerchant = {};
Map<String, String> _notesByMerchant = {};
Map<String, TxExactTemplate> _exactTemplates = {}; Map<String, TxExactTemplate> _exactTemplates = {};
List<TxLabel> _customLabels = [];
/// Resolved meta: per-id overrides + inherited category / notes rules. /// Built-in + custom labels available for picking.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
List<TxLabel> 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<TxLabel> 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) { TxMeta metaFor(BankTransaction tx) {
final stored = _byId[tx.id]; final stored = _byId[tx.id];
final template = _exactTemplates[tx.exactKey]; 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 final labelIds = stored != null && stored.hasLabel
? stored.category ? stored.labelIds
: (inheritedCategory ?? TxCategory.uncategorized); : (inheritedLabels ?? const <String>[]);
final notes = stored != null && stored.hasNotes final notes = stored != null && stored.hasNotes
? stored.notes ? stored.notes
: (template?.notes ?? ''); : (template != null && template.notes.trim().isNotEmpty
? template.notes
: (inheritedNotes ?? ''));
final recurring = stored?.recurring ?? template?.recurring ?? false; final recurring = stored?.recurring ?? template?.recurring ?? false;
return TxMeta( return TxMeta(
category: category, labelIds: List<String>.of(labelIds),
notes: notes, notes: notes,
recurring: recurring, recurring: recurring,
); );
} }
Future<void> setCategory(BankTransaction tx, TxCategory category) async { int countSimilar(
final current = metaFor(tx); BankTransaction prototype,
_byId = { List<BankTransaction> all,
..._byId, ) {
tx.id: current.copyWith(category: category), final key = prototype.merchantKey;
}; if (key.isEmpty) return 1;
if (category == TxCategory.uncategorized) { return all.where((tx) => tx.merchantKey == key).length;
_categoryByDescription = Map.of(_categoryByDescription) }
..remove(tx.descriptionKey);
} else { List<BankTransaction> similarTo(
_categoryByDescription = { BankTransaction prototype,
..._categoryByDescription, List<BankTransaction> all,
tx.descriptionKey: category, ) {
}; final key = prototype.merchantKey;
if (key.isEmpty) return [prototype];
return [
for (final tx in all)
if (tx.merchantKey == key) tx,
];
}
Future<void> setLabels(
BankTransaction tx,
List<String> labelIds, {
List<BankTransaction> applyToSimilar = const [],
}) async {
final cleaned = <String>[
for (final id in labelIds)
if (id.isNotEmpty) id,
];
// Deduplicate while preserving order.
final unique = <String>[];
for (final id in cleaned) {
if (!unique.contains(id)) unique.add(id);
} }
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
final nextById = Map<String, TxMeta>.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<String, List<String>>.of(_labelsByMerchant);
if (unique.isEmpty) {
nextRules.remove(tx.merchantKey);
nextRules.remove(tx.descriptionKey);
} else {
nextRules[tx.merchantKey] = unique;
}
_labelsByMerchant = nextRules;
await _persist();
}
Future<void> toggleLabel(
BankTransaction tx,
String labelId, {
List<BankTransaction> applyToSimilar = const [],
}) async {
final current = List<String>.of(metaFor(tx).labelIds);
if (current.contains(labelId)) {
current.remove(labelId);
} else {
current.add(labelId);
}
await setLabels(tx, current, applyToSimilar: applyToSimilar);
}
Future<void> clearLabels(
BankTransaction tx, {
List<BankTransaction> applyToSimilar = const [],
}) {
return setLabels(tx, const [], applyToSimilar: applyToSimilar);
}
Future<TxLabel> 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<void> removeLabel(String labelId) async {
_customLabels =
_customLabels.where((label) => label.id != labelId).toList();
final nextById = <String, TxMeta>{};
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(); await _persist();
} }
@@ -79,24 +220,54 @@ class TxMetaController extends ChangeNotifier {
await _persist(); await _persist();
} }
Future<void> setNotes(BankTransaction tx, String notes) async { Future<void> setNotes(
BankTransaction tx,
String notes, {
List<BankTransaction> applyToSimilar = const [],
}) async {
final trimmed = notes.trim(); final trimmed = notes.trim();
final current = metaFor(tx); final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
_byId = { final nextById = Map<String, TxMeta>.of(_byId);
..._byId, for (final other in scope) {
tx.id: current.copyWith(notes: trimmed), if (applyToSimilar.isNotEmpty &&
}; other.merchantKey != tx.merchantKey) {
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate(); continue;
if (trimmed.isEmpty && !existing.recurring && !current.recurring) { }
_exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey); 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 { } else {
_exactTemplates = { if (trimmed.isEmpty) {
..._exactTemplates, _notesByMerchant = Map.of(_notesByMerchant)
tx.exactKey: TxExactTemplate( ..remove(tx.merchantKey)
notes: trimmed, ..remove(tx.descriptionKey);
recurring: existing.recurring || current.recurring, } else {
), _notesByMerchant = {
}; ..._notesByMerchant,
tx.merchantKey: trimmed,
};
}
} }
await _persist(); await _persist();
} }
@@ -105,39 +276,43 @@ class TxMetaController extends ChangeNotifier {
return setRecurring(tx, !metaFor(tx).recurring); return setRecurring(tx, !metaFor(tx).recurring);
} }
/// Search description, notes, category label, and date. /// Search description, notes, label name, and date.
/// Optional [categories] require a matching category when non-empty.
List<BankTransaction> search( List<BankTransaction> search(
List<BankTransaction> txs, List<BankTransaction> txs,
String query, { String query, {
Set<TxCategory> categories = const {}, Set<String> labelIds = const {},
}) { }) {
final needle = query.trim().toLowerCase(); final needle = query.trim().toLowerCase();
final hasText = needle.isNotEmpty; final hasText = needle.isNotEmpty;
final hasCats = categories.isNotEmpty; final hasLabels = labelIds.isNotEmpty;
if (!hasText && !hasCats) return txs; if (!hasText && !hasLabels) return txs;
return txs.where((tx) { return txs.where((tx) {
final meta = metaFor(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; if (!hasText) return true;
final labelNames = labelsFor(meta).map((l) => l.name.toLowerCase());
return tx.description.toLowerCase().contains(needle) || return tx.description.toLowerCase().contains(needle) ||
meta.notes.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.date.toLowerCase().contains(needle) ||
tx.amount.toString().contains(needle); tx.amount.toString().contains(needle);
}).toList(growable: false); }).toList(growable: false);
} }
/// Categories currently used by [transactions] (excluding uncategorized). /// Labels currently used by [transactions].
List<TxCategory> usedCategories(List<BankTransaction> transactions) { List<TxLabel> usedLabels(List<BankTransaction> transactions) {
final found = <TxCategory>{}; final found = <String>{};
for (final tx in transactions) { for (final tx in transactions) {
final category = metaFor(tx).category; found.addAll(metaFor(tx).labelIds);
if (category != TxCategory.uncategorized) found.add(category);
} }
final list = found.toList() final list = [
..sort((a, b) => a.label.compareTo(b.label)); for (final label in allLabels)
if (found.contains(label.id)) label,
];
list.sort((a, b) => a.name.compareTo(b.name));
return list; return list;
} }
@@ -146,40 +321,62 @@ class TxMetaController extends ChangeNotifier {
await _storage.saveTxMetaStore( await _storage.saveTxMetaStore(
TxMetaStoreData( TxMetaStoreData(
byId: _byId, byId: _byId,
categoryByDescription: _categoryByDescription, labelsByMerchant: _labelsByMerchant,
notesByMerchant: _notesByMerchant,
exactTemplates: _exactTemplates, exactTemplates: _exactTemplates,
customLabels: _customLabels,
), ),
); );
} }
/// Absolute spend (outflows only) grouped by category. /// Absolute spend (outflows) per label. Multi-label txs count fully in each.
Map<TxCategory, double> spendByCategory(List<BankTransaction> txs) { Map<String, double> spendByLabel(List<BankTransaction> txs) {
final totals = <TxCategory, double>{}; final totals = <String, double>{};
for (final tx in txs) { for (final tx in txs) {
if (tx.amount >= 0) continue; if (tx.amount >= 0) continue;
final category = metaFor(tx).category; final ids = metaFor(tx).labelIds;
totals[category] = (totals[category] ?? 0) + tx.amount.abs(); if (ids.isEmpty) continue;
final amount = tx.amount.abs();
for (final id in ids) {
totals[id] = (totals[id] ?? 0) + amount;
}
} }
return totals; return totals;
} }
/// Absolute spend grouped by calendar month key `yyyy-MM`. /// Unique labeled outflow total (no double-count for multi-label txs).
Map<String, double> spendByMonth(List<BankTransaction> txs) { double labeledSpendTotal(List<BankTransaction> txs) {
final totals = <String, double>{}; var total = 0.0;
for (final tx in txs) { for (final tx in txs) {
if (tx.amount >= 0) continue; if (tx.amount >= 0) continue;
final key = _monthKey(tx.date); if (!metaFor(tx).hasLabel) continue;
if (key == null) continue; total += tx.amount.abs();
totals[key] = (totals[key] ?? 0) + tx.amount.abs();
} }
final keys = totals.keys.toList()..sort(); return total;
return {for (final key in keys) key: totals[key]!};
} }
String? _monthKey(String raw) { /// Absolute spend for each day in [month] (`yyyy-MM-dd` → amount).
final parsed = DateTime.tryParse(raw); Map<String, double> spendByDayInMonth(
if (parsed == null) return null; List<BankTransaction> txs,
final m = parsed.month.toString().padLeft(2, '0'); DateTime month,
return '${parsed.year}-$m'; ) {
final start = DateTime(month.year, month.month, 1);
final end = DateTime(month.year, month.month + 1, 0);
final totals = <String, double>{};
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;
} }
} }
+12 -6
View File
@@ -4,7 +4,6 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart'; import '../providers/bank_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/connect_bank.dart'; import '../utils/connect_bank.dart';
@@ -96,7 +95,7 @@ class _HomeBody extends StatelessWidget {
final meta = context.watch<TxMetaController>(); final meta = context.watch<TxMetaController>();
final recent = bank.transactions.take(5).toList(growable: false); final recent = bank.transactions.take(5).toList(growable: false);
final categorized = bank.transactions final categorized = bank.transactions
.where((tx) => meta.metaFor(tx).hasCategory) .where((tx) => meta.metaFor(tx).hasLabel)
.length; .length;
return ListView( return ListView(
@@ -207,10 +206,17 @@ class _HomeBody extends StatelessWidget {
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
leading: Icon( leading: Builder(
meta.metaFor(tx).category.icon, builder: (context) {
color: meta.metaFor(tx).category.color, 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( title: Text(
tx.description, tx.description,
maxLines: 1, maxLines: 1,
@@ -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<BankController>();
final meta = context.watch<TxMetaController>();
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<void>(
builder: (_) => TransactionDetailScreen(tx: tx),
),
);
},
),
],
),
),
);
}
}
+145 -52
View File
@@ -15,6 +15,7 @@ import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart'; import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
import 'label_transactions_screen.dart';
enum _StatsMode { category, month } enum _StatsMode { category, month }
@@ -29,16 +30,32 @@ class _OverviewScreenState extends State<OverviewScreen> {
_StatsMode _mode = _StatsMode.category; _StatsMode _mode = _StatsMode.category;
var _lookAheadEnabled = false; var _lookAheadEnabled = false;
DateTime _lookAheadMonth = monthStart(DateTime.now()); DateTime _lookAheadMonth = monthStart(DateTime.now());
DateTime _chartMonth = monthStart(DateTime.now());
void _goLookAheadMonth(int delta) { void _goLookAheadMonth(int delta) {
final next = addMonths(_lookAheadMonth, delta); final next = addMonths(_lookAheadMonth, delta);
final now = monthStart(DateTime.now()); 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.isBefore(addMonths(now, -24))) return;
if (delta > 0 && next.isAfter(addMonths(now, 36))) return; if (delta > 0 && next.isAfter(addMonths(now, 36))) return;
setState(() => _lookAheadMonth = next); 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<void>(
builder: (_) => LabelTransactionsScreen(label: label),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bank = context.watch<BankController>(); final bank = context.watch<BankController>();
@@ -92,9 +109,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
? bank.transactions.first.currency ? bank.transactions.first.currency
: 'EUR'); : 'EUR');
final currentBalance = bank.balance?.amount ?? 0; final currentBalance = bank.balance?.amount ?? 0;
final byCategory = meta.spendByCategory(bank.transactions); final byLabel = meta.spendByLabel(bank.transactions);
final byMonth = meta.spendByMonth(bank.transactions); final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth);
final totalSpend = byCategory.values.fold<double>(0, (sum, v) => sum + v); final totalSpend = meta.labeledSpendTotal(bank.transactions);
final recurrings = recurringItemsFrom( final recurrings = recurringItemsFrom(
transactions: bank.transactions, transactions: bank.transactions,
@@ -214,10 +231,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
for (final row in projected.take(12)) for (final row in projected.take(12))
ListTile( ListTile(
dense: true, dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding:
leading: Icon( const EdgeInsets.symmetric(horizontal: 8),
row.item.category.icon, leading: Builder(
color: row.item.category.color, 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( title: Text(
row.item.description, row.item.description,
@@ -247,7 +272,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
), ),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
Text('Money out (categorized)', style: theme.textTheme.labelMedium), Text('Money out (labeled)', style: theme.textTheme.labelMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
formatMoney(totalSpend, currency), formatMoney(totalSpend, currency),
@@ -258,13 +283,13 @@ class _OverviewScreenState extends State<OverviewScreen> {
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: _StatsMode.category, value: _StatsMode.category,
label: Text('Category'), label: Text('Label'),
icon: Icon(Icons.label_outline), icon: Icon(Icons.label_outline),
), ),
ButtonSegment( ButtonSegment(
value: _StatsMode.month, value: _StatsMode.month,
label: Text('By date'), label: Text('By day'),
icon: Icon(Icons.calendar_month_outlined), icon: Icon(Icons.bar_chart_outlined),
), ),
], ],
selected: {_mode}, selected: {_mode},
@@ -272,7 +297,33 @@ class _OverviewScreenState extends State<OverviewScreen> {
setState(() => _mode = selection.first); 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( SizedBox(
height: 260, height: 260,
child: bank.transactions.isEmpty child: bank.transactions.isEmpty
@@ -283,21 +334,27 @@ class _OverviewScreenState extends State<OverviewScreen> {
), ),
) )
: _mode == _StatsMode.category : _mode == _StatsMode.category
? _CategoryBars(data: byCategory, currency: currency) ? _LabelBars(
: _MonthBars(data: byMonth, currency: currency), data: byLabel,
resolve: meta.labelById,
currency: currency,
onBarTap: _openLabel,
)
: _DayBars(data: byDay, currency: currency),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (_mode == _StatsMode.category) if (_mode == _StatsMode.category)
..._categoryLegend(context, byCategory, currency) ..._labelLegend(context, byLabel, meta, currency)
else else
..._monthLegend(context, byMonth, currency), ..._dayLegend(context, byDay, currency),
], ],
); );
} }
List<Widget> _categoryLegend( List<Widget> _labelLegend(
BuildContext context, BuildContext context,
Map<TxCategory, double> data, Map<String, double> data,
TxMetaController meta,
String currency, String currency,
) { ) {
final entries = data.entries.toList() final entries = data.entries.toList()
@@ -305,38 +362,46 @@ class _OverviewScreenState extends State<OverviewScreen> {
if (entries.isEmpty) { if (entries.isEmpty) {
return [ return [
Text( 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, style: Theme.of(context).textTheme.bodyMedium,
), ),
]; ];
} }
return [ return [
for (final entry in entries) for (final entry in entries)
ListTile( Builder(
contentPadding: EdgeInsets.zero, builder: (context) {
dense: true, final label = meta.labelById(entry.key);
leading: Icon(entry.key.icon, color: entry.key.color), if (label == null) return const SizedBox.shrink();
title: Text(entry.key.label), return ListTile(
trailing: Text(formatMoney(entry.value, currency)), 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<Widget> _monthLegend( List<Widget> _dayLegend(
BuildContext context, BuildContext context,
Map<String, double> data, Map<String, double> data,
String currency, String currency,
) { ) {
if (data.isEmpty) { final spent = data.entries.where((e) => e.value > 0).toList().reversed;
if (spent.isEmpty) {
return [ return [
Text( Text(
'No dated outflows to chart yet.', 'No outflows this month.',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
]; ];
} }
return [ return [
for (final entry in data.entries.toList().reversed) for (final entry in spent)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
@@ -348,20 +413,31 @@ class _OverviewScreenState extends State<OverviewScreen> {
} }
} }
class _CategoryBars extends StatelessWidget { class _LabelBars extends StatelessWidget {
const _CategoryBars({required this.data, required this.currency}); const _LabelBars({
required this.data,
required this.resolve,
required this.currency,
required this.onBarTap,
});
final Map<TxCategory, double> data; final Map<String, double> data;
final TxLabel? Function(String? id) resolve;
final String currency; final String currency;
final void Function(TxLabel label) onBarTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final entries = data.entries.toList() final entries = <({TxLabel label, double value})>[
..sort((a, b) => b.value.compareTo(a.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) { if (entries.isEmpty) {
return Center( return Center(
child: Text( child: Text(
'Categorize transactions to see this chart', 'Label transactions to see this chart',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -373,11 +449,19 @@ class _CategoryBars extends StatelessWidget {
BarChartData( BarChartData(
maxY: maxY * 1.15, maxY: maxY * 1.15,
barTouchData: BarTouchData( 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( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
final entry = entries[group.x.toInt()]; final entry = entries[group.x.toInt()];
return BarTooltipItem( return BarTooltipItem(
'${entry.key.label}\n${formatMoney(entry.value, currency)}', '${entry.label.name}\n${formatMoney(entry.value, currency)}',
TextStyle( TextStyle(
color: Theme.of(context).colorScheme.onInverseSurface, color: Theme.of(context).colorScheme.onInverseSurface,
), ),
@@ -399,7 +483,7 @@ class _CategoryBars extends StatelessWidget {
if (index < 0 || index >= entries.length) { if (index < 0 || index >= entries.length) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final label = entries[index].key.label; final label = entries[index].label.name;
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
@@ -423,7 +507,7 @@ class _CategoryBars extends StatelessWidget {
width: 18, width: 18,
borderRadius: borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)), 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 { class _DayBars extends StatelessWidget {
const _MonthBars({required this.data, required this.currency}); const _DayBars({required this.data, required this.currency});
final Map<String, double> data; final Map<String, double> data;
final String currency; final String currency;
@@ -442,26 +526,30 @@ class _MonthBars extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final entries = data.entries.toList(); final entries = data.entries.toList();
if (entries.isEmpty) { if (entries.every((e) => e.value == 0)) {
return Center( return Center(
child: Text( child: Text(
'No monthly spend yet', 'No spend this month',
style: Theme.of(context).textTheme.bodyMedium, 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<double>(0, (a, b) => a > b ? a : b);
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return BarChart( return BarChart(
BarChartData( BarChartData(
maxY: maxY * 1.15, maxY: safeMax,
barTouchData: BarTouchData( barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
final entry = entries[group.x.toInt()]; final entry = entries[group.x.toInt()];
final day = entry.key.length >= 10
? entry.key.substring(8)
: entry.key;
return BarTooltipItem( return BarTooltipItem(
'${entry.key}\n${formatMoney(entry.value, currency)}', 'Day $day\n${formatMoney(entry.value, currency)}',
TextStyle(color: colors.onInverseSurface), TextStyle(color: colors.onInverseSurface),
); );
}, },
@@ -476,17 +564,20 @@ class _MonthBars extends StatelessWidget {
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
reservedSize: 28, reservedSize: 28,
interval: entries.length > 16 ? 5 : 2,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final index = value.toInt(); final index = value.toInt();
if (index < 0 || index >= entries.length) { if (index < 0 || index >= entries.length) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final key = entries[index].key; if (entries.length > 16 && index % 5 != 0) {
final short = key.length >= 7 ? key.substring(5) : key; return const SizedBox.shrink();
}
final day = (index + 1).toString();
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: Text(
short, day,
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
); );
@@ -503,10 +594,12 @@ class _MonthBars extends StatelessWidget {
barRods: [ barRods: [
BarChartRodData( BarChartRodData(
toY: entries[i].value, toY: entries[i].value,
width: 22, width: entries.length > 20 ? 6 : 8,
borderRadius: borderRadius:
const BorderRadius.vertical(top: Radius.circular(6)), const BorderRadius.vertical(top: Radius.circular(3)),
color: colors.primary, color: entries[i].value > 0
? colors.primary
: colors.surfaceContainerHighest,
), ),
], ],
), ),
+5 -6
View File
@@ -92,9 +92,9 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
const ListTile( ListTile(
leading: Icon(Icons.open_in_browser_outlined), leading: const Icon(Icons.open_in_browser_outlined),
title: Text('Enable Banking redirect'), title: const Text('Enable Banking redirect'),
subtitle: Text(kEnableBankingRedirectUri), subtitle: Text(kEnableBankingRedirectUri),
), ),
], ],
@@ -104,9 +104,8 @@ class SettingsScreen extends StatelessWidget {
Text( Text(
'Enable Banking only allows http(s) redirects. Keep ' 'Enable Banking only allows http(s) redirects. Keep '
'$kEnableBankingRedirectUri in the Control Panel and backend ' '$kEnableBankingRedirectUri in the Control Panel and backend '
'REDIRECT_URI. The app captures that URL inside a WebView. ' 'REDIRECT_URI. The app captures that callback path in a WebView. '
'Override the API host with ' 'Override with `--dart-define=API_BASE_URL=https://…`.',
'`--dart-define=API_BASE_URL=http://<host>:8000`.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 28), const SizedBox(height: 28),
+205 -24
View File
@@ -1,4 +1,4 @@
/// Transaction detail: category, recurring, and user notes. /// Transaction detail: label, recurring, notes, apply-to-similar.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../models/bank.dart'; import '../models/bank.dart';
import '../models/transaction_meta.dart'; import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
@@ -23,12 +24,18 @@ class TransactionDetailScreen extends StatefulWidget {
class _TransactionDetailScreenState extends State<TransactionDetailScreen> { class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
late final TextEditingController _notesController; late final TextEditingController _notesController;
var _dirty = false; var _dirty = false;
late bool _applyToSimilar;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final notes = context.read<TxMetaController>().metaFor(widget.tx).notes; final meta = context.read<TxMetaController>();
final notes = meta.metaFor(widget.tx).notes;
_notesController = TextEditingController(text: notes); _notesController = TextEditingController(text: notes);
final all = context.read<BankController>().transactions;
final similarCount = meta.countSimilar(widget.tx, all);
// Default on when there are peers — otherwise users never notice the toggle.
_applyToSimilar = similarCount > 1;
} }
@override @override
@@ -37,25 +44,116 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
super.dispose(); super.dispose();
} }
List<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all);
}
Future<void> _saveNotes() async { Future<void> _saveNotes() async {
await context.read<TxMetaController>().setNotes( final meta = context.read<TxMetaController>();
widget.tx, final similar = _similarScope(meta);
_notesController.text, await meta.setNotes(
); widget.tx,
_notesController.text,
applyToSimilar: similar,
);
if (!mounted) return; if (!mounted) return;
setState(() => _dirty = false); setState(() => _dirty = false);
final count = similar.isEmpty ? 1 : similar.length;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notes saved')), SnackBar(
content: Text(
count == 1
? 'Notes saved'
: 'Notes saved on $count similar transactions',
),
),
); );
} }
Future<void> _toggleLabel(String labelId) async {
final meta = context.read<TxMetaController>();
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<void> _clearLabels() async {
final meta = context.read<TxMetaController>();
final similar = _similarScope(meta);
await meta.clearLabels(
widget.tx,
applyToSimilar: similar,
);
}
Future<void> _createLabel() async {
final name = await showDialog<String>(
context: context,
builder: (context) => const _NewLabelDialog(),
);
if (name == null || name.isEmpty || !mounted) return;
final meta = context.read<TxMetaController>();
final label = await meta.createLabel(name);
if (!mounted) return;
await _toggleLabel(label.id);
}
Future<void> _confirmRemoveLabel(TxLabel label) async {
final ok = await showDialog<bool>(
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<TxMetaController>().removeLabel(label.id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tx = widget.tx; final tx = widget.tx;
final meta = context.watch<TxMetaController>().metaFor(tx); final metaController = context.watch<TxMetaController>();
final meta = metaController.metaFor(tx);
final all = context.watch<BankController>().transactions;
final similarCount = metaController.countSimilar(tx, all);
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final amountColor = tx.amount >= 0 ? colors.primary : colors.error; 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -86,7 +184,20 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
].join(' · '), ].join(' · '),
style: theme.textTheme.bodyMedium, 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( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew), secondary: const Icon(Icons.autorenew),
@@ -98,25 +209,39 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
}, },
), ),
const SizedBox(height: 16), 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), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final category in TxCategory.values) for (final label in metaController.allLabels)
ChoiceChip( GestureDetector(
avatar: Icon( onLongPress: label.custom
category.icon, ? () => _confirmRemoveLabel(label)
size: 16, : null,
color: category.color, 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<TxMetaController>().setCategory(tx, category);
},
), ),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('New label'),
onPressed: _createLabel,
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -137,12 +262,17 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton( FilledButton(
onPressed: _dirty ? _saveNotes : null, onPressed: _dirty ? _saveNotes : null,
child: const Text('Save notes'), child: Text(
_applyToSimilar && similarCount > 1
? 'Save notes for $similarCount'
: 'Save notes',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Matching descriptions reuse this category automatically. ' 'Tap labels to add or remove several. Long-press a custom label '
'Exact same amount + description also copies notes.', 'to delete it. Similar matches strip dates and ids so supermarket '
'purchases group together.',
style: theme.textTheme.labelMedium, style: theme.textTheme.labelMedium,
), ),
], ],
@@ -151,3 +281,54 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
); );
} }
} }
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'),
),
],
);
}
}
+48 -34
View File
@@ -31,7 +31,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
DateTime _month = monthStart(DateTime.now()); DateTime _month = monthStart(DateTime.now());
DateTime? _selectedDay; DateTime? _selectedDay;
var _searchInMonthOnly = false; var _searchInMonthOnly = false;
final Set<TxCategory> _categoryFilters = {}; final Set<String> _labelFilters = {};
@override @override
void dispose() { void dispose() {
@@ -40,7 +40,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
} }
bool get _isSearching => bool get _isSearching =>
_query.trim().isNotEmpty || _categoryFilters.isNotEmpty; _query.trim().isNotEmpty || _labelFilters.isNotEmpty;
void _goMonth(int delta) { void _goMonth(int delta) {
final next = addMonths(_month, delta); final next = addMonths(_month, delta);
@@ -67,15 +67,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
return isSameMonth(date, _month); return isSameMonth(date, _month);
} }
Future<void> _pickCategories(List<TxCategory> available) async { Future<void> _pickLabels(List<TxLabel> available) async {
if (available.isEmpty) { if (available.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No categories in use yet')), const SnackBar(content: Text('No labels in use yet')),
); );
return; return;
} }
final selected = Set<TxCategory>.of(_categoryFilters); final selected = Set<String>.of(_labelFilters);
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
@@ -90,7 +90,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
'Filter by category', 'Filter by label',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -98,21 +98,21 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final category in available) for (final label in available)
FilterChip( FilterChip(
avatar: Icon( avatar: Icon(
category.icon, label.icon,
size: 16, size: 16,
color: category.color, color: label.color,
), ),
label: Text(category.label), label: Text(label.name),
selected: selected.contains(category), selected: selected.contains(label.id),
onSelected: (value) { onSelected: (value) {
setSheetState(() { setSheetState(() {
if (value) { if (value) {
selected.add(category); selected.add(label.id);
} else { } else {
selected.remove(category); selected.remove(label.id);
} }
}); });
}, },
@@ -123,7 +123,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
_categoryFilters _labelFilters
..clear() ..clear()
..addAll(selected); ..addAll(selected);
}); });
@@ -241,7 +241,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
final filtered = metaController.search( final filtered = metaController.search(
searchPool, searchPool,
_query, _query,
categories: _categoryFilters, labelIds: _labelFilters,
); );
var moneyOut = 0.0; var moneyOut = 0.0;
@@ -257,7 +257,10 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
(bank.transactions.isNotEmpty (bank.transactions.isNotEmpty
? bank.transactions.first.currency ? bank.transactions.first.currency
: 'EUR'); : '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( return ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -274,13 +277,11 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search description, notes…', hintText: 'Search description, notes…',
prefixIcon: IconButton( prefixIcon: IconButton(
tooltip: 'Categories', tooltip: 'Labels',
onPressed: () => _pickCategories(usedCategories), onPressed: () => _pickLabels(usedLabels),
icon: Icon( icon: Icon(
Icons.search, Icons.search,
color: _categoryFilters.isEmpty color: _labelFilters.isEmpty ? null : colors.primary,
? null
: colors.primary,
), ),
), ),
suffixIcon: _query.isEmpty suffixIcon: _query.isEmpty
@@ -316,21 +317,26 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
], ],
), ),
), ),
if (_categoryFilters.isNotEmpty) if (_labelFilters.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final category in _categoryFilters) for (final id in _labelFilters)
InputChip( if (labelById[id] != null)
avatar: Icon(category.icon, size: 16, color: category.color), InputChip(
label: Text(category.label), avatar: Icon(
onDeleted: () { labelById[id]!.icon,
setState(() => _categoryFilters.remove(category)); size: 16,
}, color: labelById[id]!.color,
), ),
label: Text(labelById[id]!.name),
onDeleted: () {
setState(() => _labelFilters.remove(id));
},
),
], ],
), ),
), ),
@@ -411,6 +417,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
_CompactTransactionTile( _CompactTransactionTile(
tx: filtered[i], tx: filtered[i],
meta: metaController.metaFor(filtered[i]), meta: metaController.metaFor(filtered[i]),
labels: metaController
.labelsFor(metaController.metaFor(filtered[i])),
), ),
], ],
], ],
@@ -467,16 +475,21 @@ class _CompactTransactionTile extends StatelessWidget {
const _CompactTransactionTile({ const _CompactTransactionTile({
required this.tx, required this.tx,
required this.meta, required this.meta,
this.labels = const [],
}); });
final BankTransaction tx; final BankTransaction tx;
final TxMeta meta; final TxMeta meta;
final List<TxLabel> labels;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final amountColor = tx.amount >= 0 ? colors.primary : colors.error; 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( return InkWell(
onTap: () { onTap: () {
@@ -494,13 +507,13 @@ class _CompactTransactionTile extends StatelessWidget {
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: meta.category.color.withValues(alpha: 0.14), color: iconColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon( child: Icon(
meta.category.icon, iconData,
size: 18, size: 18,
color: meta.category.color, color: iconColor,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -521,7 +534,8 @@ class _CompactTransactionTile extends StatelessWidget {
Text( Text(
[ [
formatIsoDate(tx.date), formatIsoDate(tx.date),
if (meta.hasCategory) meta.category.label, if (labels.isNotEmpty)
labels.map((l) => l.name).join(', '),
if (meta.recurring) 'Recurring', if (meta.recurring) 'Recurring',
if (meta.hasNotes) 'Note', if (meta.hasNotes) 'Note',
if (tx.pending) 'Pending', if (tx.pending) 'Pending',
+167 -31
View File
@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/bank.dart';
import '../models/transaction_meta.dart'; import '../models/transaction_meta.dart';
import '../utils/constants.dart'; import '../utils/constants.dart';
@@ -12,13 +13,81 @@ import '../utils/constants.dart';
class TxMetaStoreData { class TxMetaStoreData {
const TxMetaStoreData({ const TxMetaStoreData({
this.byId = const {}, this.byId = const {},
this.categoryByDescription = const {}, this.labelsByMerchant = const {},
this.notesByMerchant = const {},
this.exactTemplates = const {}, this.exactTemplates = const {},
this.customLabels = const [],
}); });
final Map<String, TxMeta> byId; final Map<String, TxMeta> byId;
final Map<String, TxCategory> categoryByDescription; final Map<String, List<String>> labelsByMerchant;
final Map<String, String> notesByMerchant;
final Map<String, TxExactTemplate> exactTemplates; final Map<String, TxExactTemplate> exactTemplates;
final List<TxLabel> 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<BankTransaction> transactions;
Map<String, dynamic> 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<String, dynamic> json) {
final statusRaw = json['status'];
final status = statusRaw is Map
? ConnectStatus.fromJson(Map<String, dynamic>.from(statusRaw))
: ConnectStatus.disconnected;
final balanceRaw = json['balance'];
final balance = balanceRaw is Map
? BankBalance.fromJson(Map<String, dynamic>.from(balanceRaw))
: null;
final txsRaw = json['transactions'];
final transactions = txsRaw is List
? txsRaw
.whereType<Map>()
.map((e) => BankTransaction.fromJson(Map<String, dynamic>.from(e)))
.toList(growable: false)
: const <BankTransaction>[];
return BankCache(
status: status,
balance: balance,
transactions: transactions,
);
}
} }
/// Loads and stores user preferences. Call [init] once at startup. /// Loads and stores user preferences. Call [init] once at startup.
@@ -52,34 +121,54 @@ class StorageService {
return _prefs.setBool(StorageKeys.useRemoteData, value); 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<String, dynamic>.from(decoded));
} catch (_) {
return null;
}
}
Future<void> saveBankCache(BankCache cache) {
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
}
Future<void> clearBankCache() {
return _prefs.remove(StorageKeys.bankCache);
}
TxMetaStoreData loadTxMetaStore() { TxMetaStoreData loadTxMetaStore() {
final raw = _prefs.getString(StorageKeys.txMeta); final raw = _prefs.getString(StorageKeys.txMeta);
if (raw != null && raw.isNotEmpty) { if (raw != null && raw.isNotEmpty) {
return _decodeStore(raw); return _decodeStore(raw);
} }
// Migrate flat v1 map → v2 store.
final legacy = _prefs.getString(StorageKeys.txMetaLegacy); final legacy = _prefs.getString(StorageKeys.txMetaLegacy);
if (legacy == null || legacy.isEmpty) return const TxMetaStoreData(); if (legacy == null || legacy.isEmpty) return const TxMetaStoreData();
try { try {
final decoded = jsonDecode(legacy); final decoded = jsonDecode(legacy);
if (decoded is! Map) return const TxMetaStoreData(); if (decoded is! Map) return const TxMetaStoreData();
final byId = <String, TxMeta>{}; final byId = <String, TxMeta>{};
final categoryByDescription = <String, TxCategory>{}; final labelsByMerchant = <String, List<String>>{};
decoded.forEach((key, value) { decoded.forEach((key, value) {
if (key is! String || value is! Map) return; if (key is! String || value is! Map) return;
final meta = TxMeta.fromJson(Map<String, dynamic>.from(value)); final meta = TxMeta.fromJson(Map<String, dynamic>.from(value));
byId[key] = meta; byId[key] = meta;
if (meta.hasCategory) { if (meta.hasLabel) {
final desc = _descriptionFromLegacyId(key); final desc = _descriptionFromLegacyId(key);
if (desc != null) { if (desc != null) {
categoryByDescription[desc] = meta.category; labelsByMerchant[merchantKeyFromDescription(desc)] =
List<String>.of(meta.labelIds);
} }
} }
}); });
return TxMetaStoreData( return TxMetaStoreData(
byId: byId, byId: byId,
categoryByDescription: categoryByDescription, labelsByMerchant: labelsByMerchant,
); );
} catch (_) { } catch (_) {
return const TxMetaStoreData(); return const TxMetaStoreData();
@@ -91,31 +180,30 @@ class StorageService {
'byId': { 'byId': {
for (final e in store.byId.entries) e.key: e.value.toJson(), for (final e in store.byId.entries) e.key: e.value.toJson(),
}, },
'categoryByDescription': { 'labelsByMerchant': {
for (final e in store.categoryByDescription.entries) for (final e in store.labelsByMerchant.entries) e.key: e.value,
e.key: e.value.name,
}, },
// 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': { 'exactTemplates': {
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(), 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)); return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
} }
/// Deprecated shim used by older call sites during transition.
Map<String, TxMeta> loadTxMeta() => loadTxMetaStore().byId;
Future<void> saveTxMeta(Map<String, TxMeta> meta) {
final current = loadTxMetaStore();
return saveTxMetaStore(
TxMetaStoreData(
byId: meta,
categoryByDescription: current.categoryByDescription,
exactTemplates: current.exactTemplates,
),
);
}
TxMetaStoreData _decodeStore(String raw) { TxMetaStoreData _decodeStore(String raw) {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
@@ -132,12 +220,46 @@ class StorageService {
}); });
} }
final categoryByDescription = <String, TxCategory>{}; final labelsByMerchant = <String, List<String>>{};
final cats = root['categoryByDescription']; final multi = root['labelsByMerchant'];
if (cats is Map) { if (multi is Map) {
cats.forEach((key, value) { multi.forEach((key, value) {
if (key is String && value is String) { if (key is! String) return;
categoryByDescription[key] = TxCategoryX.fromName(value); 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 = <String, String>{};
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 = <TxLabel>[];
final customs = root['customLabels'];
if (customs is List) {
for (final item in customs) {
if (item is Map) {
customLabels.add(
TxLabel.fromJson(Map<String, dynamic>.from(item)),
);
}
}
}
return TxMetaStoreData( return TxMetaStoreData(
byId: byId, byId: byId,
categoryByDescription: categoryByDescription, labelsByMerchant: labelsByMerchant,
notesByMerchant: notesByMerchant,
exactTemplates: exactTemplates, exactTemplates: exactTemplates,
customLabels: customLabels,
); );
} catch (_) { } catch (_) {
return const TxMetaStoreData(); return const TxMetaStoreData();
+15 -12
View File
@@ -13,23 +13,25 @@ const Duration kSplashDuration = Duration(seconds: 2);
/// Base URL for the personal ingtrak FastAPI backend. /// Base URL for the personal ingtrak FastAPI backend.
/// ///
/// Override at build/run time: /// 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: /// Local defaults (when override omitted):
/// - Android emulator → `http://10.0.2.2:8000` (host loopback) /// - Android emulator → `http://10.0.2.2:8000`
/// - iOS Simulator → `http://127.0.0.1:8000` /// - Physical device on LAN → `http://192.168.x.x:8000`
/// - Physical device → Mac LAN IP, e.g. `http://192.168.1.114:8000`
const String kApiBaseUrl = String.fromEnvironment( const String kApiBaseUrl = String.fromEnvironment(
'API_BASE_URL', '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). /// Enable Banking redirect derived from [kApiBaseUrl].
/// Keep this exact value in the Control Panel and backend `REDIRECT_URI`. /// Must match backend `REDIRECT_URI` and the Control Panel whitelist.
/// The app opens auth in a WebView and intercepts this callback to read `code`. /// The app opens auth in a WebView and intercepts this callback path for `code`.
const String kEnableBankingRedirectUri = String get kEnableBankingRedirectUri {
'http://localhost:8000/api/connect/callback'; 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 /// Optional app-scheme bounce used after the http callback (not registered
/// with Enable Banking). /// with Enable Banking).
const String kBankRedirectScheme = 'ingtrak'; const String kBankRedirectScheme = 'ingtrak';
@@ -42,6 +44,7 @@ abstract final class StorageKeys {
static const useRemoteData = 'use_remote_data'; static const useRemoteData = 'use_remote_data';
static const txMeta = 'tx_meta_v2'; static const txMeta = 'tx_meta_v2';
static const txMetaLegacy = 'tx_meta_v1'; static const txMetaLegacy = 'tx_meta_v1';
static const bankCache = 'bank_cache_v1';
} }
/// Named routes registered on [MaterialApp]. /// Named routes registered on [MaterialApp].
+3 -3
View File
@@ -12,14 +12,14 @@ class RecurringItem {
required this.amount, required this.amount,
required this.currency, required this.currency,
required this.dayOfMonth, required this.dayOfMonth,
required this.category, this.labelIds = const [],
}); });
final String description; final String description;
final double amount; final double amount;
final String currency; final String currency;
final int dayOfMonth; final int dayOfMonth;
final TxCategory category; final List<String> labelIds;
} }
/// Build unique recurring templates from annotated transactions. /// Build unique recurring templates from annotated transactions.
@@ -51,7 +51,7 @@ List<RecurringItem> recurringItemsFrom({
amount: tx.amount, amount: tx.amount,
currency: tx.currency, currency: tx.currency,
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28), dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
category: metaFor(tx).category, labelIds: List<String>.of(metaFor(tx).labelIds),
), ),
]; ];
} }
+9 -7
View File
@@ -41,19 +41,21 @@ void main() {
expect(start.state, 'abc'); 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({ final tx = BankTransaction.fromJson({
'date': '2026-09-01', 'date': '2026-09-01',
'description': 'Coffee Shop', 'description':
'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: C00338123',
'amount': -3.5, 'amount': -3.5,
'currency': 'EUR', 'currency': 'EUR',
'pending': false, 'pending': false,
}); });
expect(tx.descriptionKey, 'coffee shop'); expect(tx.descriptionKey, contains('pasvolgnr'));
expect(tx.exactKey, contains('coffee shop')); expect(tx.merchantKey, contains('ah campus diemen'));
expect(tx.merchantKey.contains('pasvolgnr'), isFalse);
expect(tx.exactKey, contains('-3.5')); expect(tx.exactKey, contains('-3.5'));
expect(formatMoney(tx.amount, tx.currency), contains('EUR')); 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', () { test('look-ahead applies monthly recurring amounts', () {
@@ -63,14 +65,14 @@ void main() {
amount: 2000, amount: 2000,
currency: 'EUR', currency: 'EUR',
dayOfMonth: 25, dayOfMonth: 25,
category: TxCategory.salary, labelIds: ['salary'],
), ),
const RecurringItem( const RecurringItem(
description: 'Rent', description: 'Rent',
amount: -900, amount: -900,
currency: 'EUR', currency: 'EUR',
dayOfMonth: 1, dayOfMonth: 1,
category: TxCategory.mortgage, labelIds: ['mortgage'],
), ),
]; ];
final from = DateTime(2026, 9, 6); final from = DateTime(2026, 9, 6);