Files
ingtrakapp/lib/models/transaction_meta.dart
T
2026-09-10 00:35:54 +02:00

612 lines
16 KiB
Dart

/// 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<String, dynamic> 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<String, dynamic> 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<LabelTreeEntry> flattenLabelTree(
Iterable<TxLabel> labels, {
String? Function(String id)? parentOf,
}) {
final byId = <String, TxLabel>{
for (final label in labels) label.id: label,
};
String? resolveParent(String id) {
if (parentOf != null) return parentOf(id);
return byId[id]?.parentId;
}
final children = <String?, List<TxLabel>>{};
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 = <LabelTreeEntry>[];
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<String> visibleIds,
String? Function(String id) parentOf,
) {
var depth = 0;
var parent = parentOf(labelId);
final seen = <String>{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<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.
class TxMeta {
const TxMeta({
this.labelIds = const [],
this.recurring = false,
this.internalTransfer = false,
this.notes = '',
});
/// Zero or more label ids on this transaction.
final List<String> labelIds;
final bool recurring;
/// Move between own accounts — excluded from income/expense cashflow.
final bool internalTransfer;
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<String>? labelIds,
bool clearLabels = false,
bool? recurring,
bool? internalTransfer,
String? notes,
}) {
return TxMeta(
labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds),
recurring: recurring ?? this.recurring,
internalTransfer: internalTransfer ?? this.internalTransfer,
notes: notes ?? this.notes,
);
}
Map<String, dynamic> toJson() => {
'labelIds': labelIds,
// Legacy single-label keys for older builds.
'labelId': primaryLabelId,
'category': primaryLabelId,
'recurring': recurring,
'internalTransfer': internalTransfer,
'notes': notes,
};
factory TxMeta.fromJson(Map<String, dynamic> json) {
return TxMeta(
labelIds: _parseLabelIds(json),
recurring: json['recurring'] as bool? ?? false,
internalTransfer: json['internalTransfer'] as bool? ?? false,
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 / transfer template keyed by exact merchant+amount match.
class TxExactTemplate {
const TxExactTemplate({
this.notes = '',
this.recurring = false,
this.internalTransfer = false,
});
final String notes;
final bool recurring;
final bool internalTransfer;
Map<String, dynamic> toJson() => {
'notes': notes,
'recurring': recurring,
'internalTransfer': internalTransfer,
};
factory TxExactTemplate.fromJson(Map<String, dynamic> json) {
return TxExactTemplate(
notes: json['notes'] as String? ?? '',
recurring: json['recurring'] as bool? ?? false,
internalTransfer: json['internalTransfer'] as bool? ?? false,
);
}
}
/// How a [RecurringTemplate] resolves its projected amount.
enum RecurringAmountRule {
lastValue,
avgLastTxs,
labelAvgMonths,
}
/// What a recurring template is anchored to.
enum RecurringTemplateSource {
transaction,
label,
}
/// First-class look-ahead recurring template (manual or auto from marked txs).
class RecurringTemplate {
const RecurringTemplate({
required this.id,
required this.name,
required this.dayOfMonth,
required this.currency,
required this.source,
required this.amountRule,
this.merchantKey = '',
this.sourceTxId,
this.labelId,
this.avgCount = 5,
this.autoFromRecurring = false,
});
final String id;
final String name;
final int dayOfMonth;
final String currency;
final RecurringTemplateSource source;
final RecurringAmountRule amountRule;
final String merchantKey;
final String? sourceTxId;
final String? labelId;
final int avgCount;
final bool autoFromRecurring;
RecurringTemplate copyWith({
String? id,
String? name,
int? dayOfMonth,
String? currency,
RecurringTemplateSource? source,
RecurringAmountRule? amountRule,
String? merchantKey,
String? sourceTxId,
bool clearSourceTxId = false,
String? labelId,
bool clearLabelId = false,
int? avgCount,
bool? autoFromRecurring,
}) {
return RecurringTemplate(
id: id ?? this.id,
name: name ?? this.name,
dayOfMonth: dayOfMonth ?? this.dayOfMonth,
currency: currency ?? this.currency,
source: source ?? this.source,
amountRule: amountRule ?? this.amountRule,
merchantKey: merchantKey ?? this.merchantKey,
sourceTxId: clearSourceTxId ? null : (sourceTxId ?? this.sourceTxId),
labelId: clearLabelId ? null : (labelId ?? this.labelId),
avgCount: avgCount ?? this.avgCount,
autoFromRecurring: autoFromRecurring ?? this.autoFromRecurring,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'dayOfMonth': dayOfMonth,
'currency': currency,
'source': source.name,
'amountRule': amountRule.name,
'merchantKey': merchantKey,
'sourceTxId': sourceTxId,
'labelId': labelId,
'avgCount': avgCount,
'autoFromRecurring': autoFromRecurring,
};
factory RecurringTemplate.fromJson(Map<String, dynamic> json) {
return RecurringTemplate(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? 'Recurring',
dayOfMonth: (json['dayOfMonth'] as num?)?.toInt().clamp(1, 28) ?? 1,
currency: json['currency'] as String? ?? 'EUR',
source: _parseTemplateSource(json['source'] as String?),
amountRule: _parseAmountRule(json['amountRule'] as String?),
merchantKey: json['merchantKey'] as String? ?? '',
sourceTxId: json['sourceTxId'] as String?,
labelId: json['labelId'] as String?,
avgCount: (json['avgCount'] as num?)?.toInt().clamp(1, 36) ?? 5,
autoFromRecurring: json['autoFromRecurring'] as bool? ?? false,
);
}
/// Stable id for auto templates linked to a bank transaction.
static String autoIdForTx(String txId) => 'auto_${txId.hashCode.toRadixString(16)}';
}
RecurringTemplateSource _parseTemplateSource(String? raw) {
return switch (raw) {
'label' => RecurringTemplateSource.label,
_ => RecurringTemplateSource.transaction,
};
}
RecurringAmountRule _parseAmountRule(String? raw) {
return switch (raw) {
'avgLastTxs' => RecurringAmountRule.avgLastTxs,
'labelAvgMonths' => RecurringAmountRule.labelAvgMonths,
_ => RecurringAmountRule.lastValue,
};
}
/// 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);
}