/// Transaction labels (built-in + user-created) and local annotations. library; import 'package:flutter/material.dart'; import 'bank.dart'; /// 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, this.parentId, }); final String id; final String name; final Color color; final IconData icon; final bool custom; /// Optional parent label id for nesting (sub-labels). final String? parentId; bool get isRoot => parentId == null || parentId!.isEmpty; TxLabel copyWith({ String? name, Color? color, IconData? icon, String? parentId, bool clearParent = false, }) { return TxLabel( id: id, name: name ?? this.name, color: color ?? this.color, icon: icon ?? this.icon, custom: custom, parentId: clearParent ? null : (parentId ?? this.parentId), ); } Map toJson() => { 'id': id, 'name': name, // ignore: deprecated_member_use — wide compatibility across Flutter versions 'color': color.value, 'custom': custom, if (parentId != null && parentId!.isNotEmpty) 'parentId': parentId, }; factory TxLabel.fromJson(Map json) { final parent = json['parentId'] as String?; 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, parentId: (parent != null && parent.isNotEmpty) ? parent : null, ); } } /// One row in a depth-first label tree walk. class LabelTreeEntry { const LabelTreeEntry({required this.label, required this.depth}); final TxLabel label; final int depth; } /// Max nesting depth (root = 0). const int kMaxLabelDepth = 3; /// Depth-first tree order using each label's [TxLabel.parentId]. List flattenLabelTree( Iterable labels, { String? Function(String id)? parentOf, }) { final byId = { for (final label in labels) label.id: label, }; String? resolveParent(String id) { if (parentOf != null) return parentOf(id); return byId[id]?.parentId; } final children = >{}; for (final label in byId.values) { final parent = resolveParent(label.id); final key = (parent != null && byId.containsKey(parent)) ? parent : null; (children[key] ??= []).add(label); } for (final list in children.values) { list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); } final out = []; void walk(String? parentId, int depth) { final kids = children[parentId]; if (kids == null) return; for (final label in kids) { out.add(LabelTreeEntry(label: label, depth: depth)); if (depth < kMaxLabelDepth) { walk(label.id, depth + 1); } } } walk(null, 0); return out; } /// Visible indent depth among [visibleIds] only (skip missing ancestors). int labelVisibleDepth( String labelId, Set visibleIds, String? Function(String id) parentOf, ) { var depth = 0; var parent = parentOf(labelId); final seen = {labelId}; while (parent != null && parent.isNotEmpty && seen.add(parent)) { if (visibleIds.contains(parent)) depth++; parent = parentOf(parent); } return depth; } /// Filter-only id for txs with no labels (not an assignable category). const String kUncategorizedFilterId = '__uncategorized__'; /// Chip presentation for [kUncategorizedFilterId] refine filters. const TxLabel kUncategorizedFilterLabel = TxLabel( id: kUncategorizedFilterId, name: 'Uncategorized', color: Color(0xFF64748B), icon: Icons.label_off_outlined, ); /// Built-in presets (no "uncategorized" / "other" as assignable labels). 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.labelIds = const [], this.recurring = false, this.notes = '', }); /// Zero or more label ids on this transaction. final List labelIds; final bool recurring; final String notes; static const empty = TxMeta(); 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({ List? labelIds, bool clearLabels = false, bool? recurring, String? notes, }) { return TxMeta( labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds), recurring: recurring ?? this.recurring, notes: notes ?? this.notes, ); } Map toJson() => { 'labelIds': labelIds, // Legacy single-label keys for older builds. 'labelId': primaryLabelId, 'category': primaryLabelId, 'recurring': recurring, 'notes': notes, }; factory TxMeta.fromJson(Map json) { return TxMeta( 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}); final String notes; final bool recurring; Map toJson() => { 'notes': notes, 'recurring': recurring, }; factory TxExactTemplate.fromJson(Map json) { return TxExactTemplate( notes: json['notes'] as String? ?? '', recurring: json['recurring'] as bool? ?? false, ); } } /// Strip bank noise so supermarket / POS lines group by merchant stem. /// /// Example: `BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr:…` → `ah campus` /// Example: `Albert Heijn 1562 Utrecht` → `albert heijn` String merchantKeyFromDescription(String description) { var s = description .trim() .toLowerCase() .replaceAll('·', ' ') .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'^bck\*'), '') .replaceAll(RegExp(r'\s+'), ' ') .trim(); // Drop trailing country / city noise and store / terminal numbers. s = s.replaceAll( RegExp( r'\s+(nld|nl|nederland|netherlands|bel|belgium|deu|germany|de)\s*$', ), '', ); // Remove digit runs (store ids, amounts embedded in text, etc.). s = s.replaceAll(RegExp(r'\b\d+\b'), ' '); s = s.replaceAll(RegExp(r'\s+'), ' ').trim(); // Keep a short stem so "ah campus diemen zuid" ≈ "ah campus". final words = [ for (final w in s.split(' ')) if (w.length > 1) w, ]; if (words.isEmpty) return s; if (words.length <= 2) return words.join(' '); // Prefer first two tokens for long POS strings; three when the 2nd is tiny // ("de", "van") so "albert heijn" / "jumbo city" stay intact. if (words[1].length <= 2 && words.length >= 3) { return words.take(3).join(' '); } return words.take(2).join(' '); } /// Default “apply to similar” pattern — usually the merchant stem. String defaultSimilarPattern(String description) => merchantKeyFromDescription(description); /// Income / expense / any — used by list filters and similar matching. enum TxFlowFilter { all, income, expense } /// Absolute-amount filter for transaction lists (exact and/or range). class TxAmountRange { const TxAmountRange({this.exact, this.min, this.max}); /// Exact absolute amount; when set, takes precedence over [min]/[max]. final double? exact; /// Inclusive lower bound on absolute amount; null = no lower bound. final double? min; /// Inclusive upper bound on absolute amount; null = no upper bound. final double? max; bool get isActive => exact != null || min != null || max != null; } bool txMatchesAmountRange(BankTransaction tx, TxAmountRange range) { if (!range.isActive) return true; final abs = tx.amount.abs(); if (range.exact != null) { // Match to the nearest cent. return (abs - range.exact!).abs() < 0.005; } if (range.min != null && abs < range.min!) return false; if (range.max != null && abs > range.max!) return false; return true; } /// Criteria for finding similar transactions (apply-to-similar). class SimilarMatchCriteria { const SimilarMatchCriteria({ required this.pattern, this.flow = TxFlowFilter.all, }); /// Plain contains needle, or `/regex/`. final String pattern; /// Limit matches to income, expense, or either. final TxFlowFilter flow; SimilarMatchCriteria copyWith({ String? pattern, TxFlowFilter? flow, }) { return SimilarMatchCriteria( pattern: pattern ?? this.pattern, flow: flow ?? this.flow, ); } } bool txMatchesFlow(BankTransaction tx, TxFlowFilter flow) { switch (flow) { case TxFlowFilter.all: return true; case TxFlowFilter.income: return tx.amount > 0; case TxFlowFilter.expense: return tx.amount < 0; } } bool txMatchesSimilarCriteria(BankTransaction tx, SimilarMatchCriteria criteria) { if (!txMatchesFlow(tx, criteria.flow)) return false; return descriptionMatchesPattern(tx.description, criteria.pattern); } /// Match [description] against a simple contains needle or `/regex/`. bool descriptionMatchesPattern(String description, String pattern) { final raw = pattern.trim(); if (raw.isEmpty) return false; final hay = description.toLowerCase(); if (raw.length >= 3 && raw.startsWith('/') && raw.endsWith('/')) { final body = raw.substring(1, raw.length - 1); if (body.isEmpty) return false; try { return RegExp(body, caseSensitive: false).hasMatch(description); } catch (_) { return hay.contains(raw.toLowerCase()); } } final needle = raw.toLowerCase(); if (needle.length < 2) return false; return hay.contains(needle); }