244 lines
5.7 KiB
Dart
244 lines
5.7 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,
|
|
});
|
|
|
|
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,
|
|
};
|
|
|
|
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.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 "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;
|
|
}
|