ing connected, makign some features
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user