404 lines
10 KiB
Dart
404 lines
10 KiB
Dart
/// Transaction labels (built-in + user-created) and local annotations.
|
|
library;
|
|
|
|
import 'package:flutter/material.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.notes = '',
|
|
});
|
|
|
|
/// Zero or more label ids on this transaction.
|
|
final List<String> 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<String>? 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<String, dynamic> toJson() => {
|
|
'labelIds': labelIds,
|
|
// Legacy single-label keys for older builds.
|
|
'labelId': primaryLabelId,
|
|
'category': primaryLabelId,
|
|
'recurring': recurring,
|
|
'notes': notes,
|
|
};
|
|
|
|
factory TxMeta.fromJson(Map<String, dynamic> json) {
|
|
return TxMeta(
|
|
labelIds: _parseLabelIds(json),
|
|
recurring: json['recurring'] 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 template keyed by exact merchant+amount match.
|
|
class TxExactTemplate {
|
|
const TxExactTemplate({this.notes = '', this.recurring = false});
|
|
|
|
final String notes;
|
|
final bool recurring;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'notes': notes,
|
|
'recurring': recurring,
|
|
};
|
|
|
|
factory TxExactTemplate.fromJson(Map<String, dynamic> 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);
|
|
|
|
/// 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);
|
|
}
|
|
|