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
+5
View File
@@ -1,6 +1,8 @@
/// Domain models for the personal ingtrak bank-data API.
library;
import 'transaction_meta.dart' show merchantKeyFromDescription;
/// Result of `POST /api/connect`.
class ConnectStart {
const ConnectStart({required this.authUrl, required this.state});
@@ -101,6 +103,9 @@ class BankTransaction {
String get descriptionKey =>
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).
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;
import 'package:flutter/material.dart';
/// User-facing spend / income categories (stored locally, not on the bank API).
enum TxCategory {
uncategorized,
groceries,
energy,
salary,
mortgage,
transport,
dining,
shopping,
health,
transfer,
other,
}
/// 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,
});
extension TxCategoryX on TxCategory {
String get label => switch (this) {
TxCategory.uncategorized => 'Uncategorized',
TxCategory.groceries => 'Groceries',
TxCategory.energy => 'Energy',
TxCategory.salary => 'Salary',
TxCategory.mortgage => 'Mortgage',
TxCategory.transport => 'Transport',
TxCategory.dining => 'Dining',
TxCategory.shopping => 'Shopping',
TxCategory.health => 'Health',
TxCategory.transfer => 'Transfer',
TxCategory.other => 'Other',
final String id;
final String name;
final Color color;
final IconData icon;
final bool custom;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
// ignore: deprecated_member_use — wide compatibility across Flutter versions
'color': color.value,
'custom': custom,
};
IconData get icon => switch (this) {
TxCategory.uncategorized => Icons.label_outline,
TxCategory.groceries => Icons.shopping_cart_outlined,
TxCategory.energy => Icons.bolt_outlined,
TxCategory.salary => Icons.payments_outlined,
TxCategory.mortgage => Icons.home_work_outlined,
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,
factory TxLabel.fromJson(Map<String, dynamic> json) {
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,
);
}
}
/// 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.
class TxMeta {
const TxMeta({
this.category = TxCategory.uncategorized,
this.labelIds = const [],
this.recurring = false,
this.notes = '',
});
final TxCategory category;
/// Zero or more label ids on this transaction.
final List<String> labelIds;
final bool recurring;
final String notes;
static const empty = TxMeta();
bool get hasCategory => category != TxCategory.uncategorized;
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({
TxCategory? category,
List<String>? labelIds,
bool clearLabels = false,
bool? recurring,
String? notes,
}) {
return TxMeta(
category: category ?? this.category,
labelIds: clearLabels ? const [] : (labelIds ?? this.labelIds),
recurring: recurring ?? this.recurring,
notes: notes ?? this.notes,
);
}
Map<String, dynamic> toJson() => {
'category': category.name,
'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(
category: TxCategoryX.fromName(json['category'] as String?),
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});
@@ -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;
}