nice overview working

This commit is contained in:
davmar
2026-09-08 23:52:54 +02:00
parent e7a7b1a357
commit 2a741c7f2a
16 changed files with 3512 additions and 810 deletions
+160 -4
View File
@@ -11,6 +11,7 @@ class TxLabel {
required this.color,
this.icon = Icons.label_outline,
this.custom = false,
this.parentId,
});
final String id;
@@ -19,25 +20,127 @@ class TxLabel {
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,
);
}
}
/// Built-in presets (no "uncategorized" / "other").
/// 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',
@@ -205,7 +308,10 @@ class TxExactTemplate {
}
}
/// Strip bank noise so "AH Campus … Transactie:123" matches other AH Campus txs.
/// 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()
@@ -240,8 +346,58 @@ String merchantKeyFromDescription(String description) {
}
s = s
.replaceAll(RegExp(r'\s+nld\s*$'), '')
.replaceAll(RegExp(r'^bck\*'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return s;
// 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);
}