ing connected, makign some features
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bank = BankController()..refresh();
|
||||
_bank = BankController(storage: widget.storage)..refresh();
|
||||
_appLinks = AppLinks();
|
||||
_listenForBankRedirects();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,23 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../services/ingtrak_api.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/constants.dart';
|
||||
|
||||
/// App-wide banking state backed by the local ingtrak API.
|
||||
class BankController extends ChangeNotifier {
|
||||
BankController({IngtrakApi? api}) : _api = api ?? IngtrakApi();
|
||||
BankController({IngtrakApi? api, this._storage})
|
||||
: _api = api ?? IngtrakApi() {
|
||||
final cached = _storage?.loadBankCache();
|
||||
if (cached != null && cached.status.linked) {
|
||||
_status = cached.status;
|
||||
_balance = cached.balance;
|
||||
_transactions = cached.transactions;
|
||||
}
|
||||
}
|
||||
|
||||
final IngtrakApi _api;
|
||||
final StorageService? _storage;
|
||||
|
||||
ConnectStatus _status = ConnectStatus.disconnected;
|
||||
BankBalance? _balance;
|
||||
@@ -32,6 +42,22 @@ class BankController extends ChangeNotifier {
|
||||
String get apiBaseUrl => _api.baseUrl;
|
||||
String? get pendingState => _pendingState;
|
||||
|
||||
Future<void> _persistCache() async {
|
||||
final storage = _storage;
|
||||
if (storage == null) return;
|
||||
if (!_status.linked) {
|
||||
await storage.clearBankCache();
|
||||
return;
|
||||
}
|
||||
await storage.saveBankCache(
|
||||
BankCache(
|
||||
status: _status,
|
||||
balance: _balance,
|
||||
transactions: _transactions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Load status; if linked, also load balance + transactions.
|
||||
Future<void> refresh() async {
|
||||
_isLoading = true;
|
||||
@@ -42,15 +68,26 @@ class BankController extends ChangeNotifier {
|
||||
_status = await _api.fetchStatus();
|
||||
if (_status.linked) {
|
||||
await _loadLinkedData();
|
||||
await _persistCache();
|
||||
} else {
|
||||
_balance = null;
|
||||
_transactions = const [];
|
||||
await _persistCache();
|
||||
}
|
||||
} on IngtrakApiException catch (error, stackTrace) {
|
||||
// Keep cached linked data on transient API errors.
|
||||
if (!isLinked || (_balance == null && _transactions.isEmpty)) {
|
||||
_error = error.message;
|
||||
} else {
|
||||
_error = 'Using saved data · ${error.message}';
|
||||
}
|
||||
debugPrint('BankController.refresh failed: $error\n$stackTrace');
|
||||
} catch (error, stackTrace) {
|
||||
if (!isLinked || (_balance == null && _transactions.isEmpty)) {
|
||||
_error = 'Could not reach $apiBaseUrl';
|
||||
} else {
|
||||
_error = 'Using saved data · offline/unreachable API';
|
||||
}
|
||||
debugPrint('BankController.refresh failed: $error\n$stackTrace');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
@@ -73,8 +110,12 @@ class BankController extends ChangeNotifier {
|
||||
_balance = null;
|
||||
_transactions = const [];
|
||||
_error = error.message;
|
||||
} else {
|
||||
await _persistCache();
|
||||
} else if (_balance == null && _transactions.isEmpty) {
|
||||
_error = error.message;
|
||||
} else {
|
||||
// Keep previous balance/txs if refresh of data fails but still linked.
|
||||
_error = 'Using saved data · ${error.message}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +197,7 @@ class BankController extends ChangeNotifier {
|
||||
_pendingState = null;
|
||||
if (_status.linked) {
|
||||
await _loadLinkedData();
|
||||
await _persistCache();
|
||||
}
|
||||
return _status.linked;
|
||||
} on IngtrakApiException catch (error, stackTrace) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Local category / notes / recurring annotations for bank transactions.
|
||||
/// Local labels / notes / recurring annotations for bank transactions.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../models/transaction_meta.dart';
|
||||
@@ -11,54 +11,195 @@ class TxMetaController extends ChangeNotifier {
|
||||
TxMetaController(this._storage) {
|
||||
final loaded = _storage.loadTxMetaStore();
|
||||
_byId = Map.of(loaded.byId);
|
||||
_categoryByDescription = Map.of(loaded.categoryByDescription);
|
||||
_labelsByMerchant = {
|
||||
for (final e in loaded.labelsByMerchant.entries)
|
||||
e.key: List<String>.of(e.value),
|
||||
};
|
||||
_notesByMerchant = Map.of(loaded.notesByMerchant);
|
||||
_exactTemplates = Map.of(loaded.exactTemplates);
|
||||
_customLabels = List.of(loaded.customLabels);
|
||||
}
|
||||
|
||||
final StorageService _storage;
|
||||
|
||||
Map<String, TxMeta> _byId = {};
|
||||
Map<String, TxCategory> _categoryByDescription = {};
|
||||
Map<String, List<String>> _labelsByMerchant = {};
|
||||
Map<String, String> _notesByMerchant = {};
|
||||
Map<String, TxExactTemplate> _exactTemplates = {};
|
||||
List<TxLabel> _customLabels = [];
|
||||
|
||||
/// Resolved meta: per-id overrides + inherited category / notes rules.
|
||||
/// Built-in + custom labels available for picking.
|
||||
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
|
||||
|
||||
List<TxLabel> get customLabels => List.unmodifiable(_customLabels);
|
||||
|
||||
TxLabel? labelById(String? id) {
|
||||
if (id == null || id.isEmpty) return null;
|
||||
for (final label in allLabels) {
|
||||
if (label.id == id) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<TxLabel> labelsFor(TxMeta meta) {
|
||||
return [
|
||||
for (final id in meta.labelIds)
|
||||
if (labelById(id) != null) labelById(id)!,
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolved meta: per-id overrides + inherited merchant rules.
|
||||
TxMeta metaFor(BankTransaction tx) {
|
||||
final stored = _byId[tx.id];
|
||||
final template = _exactTemplates[tx.exactKey];
|
||||
final inheritedCategory = _categoryByDescription[tx.descriptionKey];
|
||||
final inheritedLabels = _labelsByMerchant[tx.merchantKey] ??
|
||||
_labelsByMerchant[tx.descriptionKey];
|
||||
final inheritedNotes = _notesByMerchant[tx.merchantKey] ??
|
||||
_notesByMerchant[tx.descriptionKey];
|
||||
|
||||
final category = stored != null && stored.hasCategory
|
||||
? stored.category
|
||||
: (inheritedCategory ?? TxCategory.uncategorized);
|
||||
final labelIds = stored != null && stored.hasLabel
|
||||
? stored.labelIds
|
||||
: (inheritedLabels ?? const <String>[]);
|
||||
|
||||
final notes = stored != null && stored.hasNotes
|
||||
? stored.notes
|
||||
: (template?.notes ?? '');
|
||||
: (template != null && template.notes.trim().isNotEmpty
|
||||
? template.notes
|
||||
: (inheritedNotes ?? ''));
|
||||
|
||||
final recurring = stored?.recurring ?? template?.recurring ?? false;
|
||||
|
||||
return TxMeta(
|
||||
category: category,
|
||||
labelIds: List<String>.of(labelIds),
|
||||
notes: notes,
|
||||
recurring: recurring,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setCategory(BankTransaction tx, TxCategory category) async {
|
||||
final current = metaFor(tx);
|
||||
_byId = {
|
||||
..._byId,
|
||||
tx.id: current.copyWith(category: category),
|
||||
};
|
||||
if (category == TxCategory.uncategorized) {
|
||||
_categoryByDescription = Map.of(_categoryByDescription)
|
||||
..remove(tx.descriptionKey);
|
||||
} else {
|
||||
_categoryByDescription = {
|
||||
..._categoryByDescription,
|
||||
tx.descriptionKey: category,
|
||||
};
|
||||
int countSimilar(
|
||||
BankTransaction prototype,
|
||||
List<BankTransaction> all,
|
||||
) {
|
||||
final key = prototype.merchantKey;
|
||||
if (key.isEmpty) return 1;
|
||||
return all.where((tx) => tx.merchantKey == key).length;
|
||||
}
|
||||
|
||||
List<BankTransaction> similarTo(
|
||||
BankTransaction prototype,
|
||||
List<BankTransaction> all,
|
||||
) {
|
||||
final key = prototype.merchantKey;
|
||||
if (key.isEmpty) return [prototype];
|
||||
return [
|
||||
for (final tx in all)
|
||||
if (tx.merchantKey == key) tx,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> setLabels(
|
||||
BankTransaction tx,
|
||||
List<String> labelIds, {
|
||||
List<BankTransaction> applyToSimilar = const [],
|
||||
}) async {
|
||||
final cleaned = <String>[
|
||||
for (final id in labelIds)
|
||||
if (id.isNotEmpty) id,
|
||||
];
|
||||
// Deduplicate while preserving order.
|
||||
final unique = <String>[];
|
||||
for (final id in cleaned) {
|
||||
if (!unique.contains(id)) unique.add(id);
|
||||
}
|
||||
|
||||
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
|
||||
final nextById = Map<String, TxMeta>.of(_byId);
|
||||
for (final other in scope) {
|
||||
if (applyToSimilar.isNotEmpty &&
|
||||
other.merchantKey != tx.merchantKey) {
|
||||
continue;
|
||||
}
|
||||
final current = metaFor(other);
|
||||
nextById[other.id] = unique.isEmpty
|
||||
? current.copyWith(clearLabels: true)
|
||||
: current.copyWith(labelIds: unique);
|
||||
}
|
||||
_byId = nextById;
|
||||
|
||||
final nextRules = Map<String, List<String>>.of(_labelsByMerchant);
|
||||
if (unique.isEmpty) {
|
||||
nextRules.remove(tx.merchantKey);
|
||||
nextRules.remove(tx.descriptionKey);
|
||||
} else {
|
||||
nextRules[tx.merchantKey] = unique;
|
||||
}
|
||||
_labelsByMerchant = nextRules;
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> toggleLabel(
|
||||
BankTransaction tx,
|
||||
String labelId, {
|
||||
List<BankTransaction> applyToSimilar = const [],
|
||||
}) async {
|
||||
final current = List<String>.of(metaFor(tx).labelIds);
|
||||
if (current.contains(labelId)) {
|
||||
current.remove(labelId);
|
||||
} else {
|
||||
current.add(labelId);
|
||||
}
|
||||
await setLabels(tx, current, applyToSimilar: applyToSimilar);
|
||||
}
|
||||
|
||||
Future<void> clearLabels(
|
||||
BankTransaction tx, {
|
||||
List<BankTransaction> applyToSimilar = const [],
|
||||
}) {
|
||||
return setLabels(tx, const [], applyToSimilar: applyToSimilar);
|
||||
}
|
||||
|
||||
Future<TxLabel> createLabel(String name) async {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw ArgumentError('Label name required');
|
||||
}
|
||||
final color =
|
||||
kCustomLabelColors[_customLabels.length % kCustomLabelColors.length];
|
||||
final label = TxLabel(
|
||||
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: trimmed,
|
||||
color: color,
|
||||
icon: Icons.label_outline,
|
||||
custom: true,
|
||||
);
|
||||
_customLabels = [..._customLabels, label];
|
||||
await _persist();
|
||||
return label;
|
||||
}
|
||||
|
||||
Future<void> removeLabel(String labelId) async {
|
||||
_customLabels =
|
||||
_customLabels.where((label) => label.id != labelId).toList();
|
||||
final nextById = <String, TxMeta>{};
|
||||
for (final entry in _byId.entries) {
|
||||
if (entry.value.labelIds.contains(labelId)) {
|
||||
final next = entry.value.labelIds.where((id) => id != labelId).toList();
|
||||
nextById[entry.key] = next.isEmpty
|
||||
? entry.value.copyWith(clearLabels: true)
|
||||
: entry.value.copyWith(labelIds: next);
|
||||
} else {
|
||||
nextById[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_byId = nextById;
|
||||
_labelsByMerchant = {
|
||||
for (final entry in _labelsByMerchant.entries)
|
||||
if (entry.value.any((id) => id != labelId))
|
||||
entry.key: [
|
||||
for (final id in entry.value)
|
||||
if (id != labelId) id,
|
||||
],
|
||||
};
|
||||
await _persist();
|
||||
}
|
||||
|
||||
@@ -79,13 +220,26 @@ class TxMetaController extends ChangeNotifier {
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> setNotes(BankTransaction tx, String notes) async {
|
||||
Future<void> setNotes(
|
||||
BankTransaction tx,
|
||||
String notes, {
|
||||
List<BankTransaction> applyToSimilar = const [],
|
||||
}) async {
|
||||
final trimmed = notes.trim();
|
||||
final scope = applyToSimilar.isEmpty ? [tx] : applyToSimilar;
|
||||
final nextById = Map<String, TxMeta>.of(_byId);
|
||||
for (final other in scope) {
|
||||
if (applyToSimilar.isNotEmpty &&
|
||||
other.merchantKey != tx.merchantKey) {
|
||||
continue;
|
||||
}
|
||||
final current = metaFor(other);
|
||||
nextById[other.id] = current.copyWith(notes: trimmed);
|
||||
}
|
||||
_byId = nextById;
|
||||
|
||||
if (applyToSimilar.isEmpty) {
|
||||
final current = metaFor(tx);
|
||||
_byId = {
|
||||
..._byId,
|
||||
tx.id: current.copyWith(notes: trimmed),
|
||||
};
|
||||
final existing = _exactTemplates[tx.exactKey] ?? const TxExactTemplate();
|
||||
if (trimmed.isEmpty && !existing.recurring && !current.recurring) {
|
||||
_exactTemplates = Map.of(_exactTemplates)..remove(tx.exactKey);
|
||||
@@ -98,6 +252,23 @@ class TxMetaController extends ChangeNotifier {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (trimmed.isEmpty) {
|
||||
_notesByMerchant = Map.of(_notesByMerchant)
|
||||
..remove(tx.merchantKey)
|
||||
..remove(tx.descriptionKey);
|
||||
}
|
||||
} else {
|
||||
if (trimmed.isEmpty) {
|
||||
_notesByMerchant = Map.of(_notesByMerchant)
|
||||
..remove(tx.merchantKey)
|
||||
..remove(tx.descriptionKey);
|
||||
} else {
|
||||
_notesByMerchant = {
|
||||
..._notesByMerchant,
|
||||
tx.merchantKey: trimmed,
|
||||
};
|
||||
}
|
||||
}
|
||||
await _persist();
|
||||
}
|
||||
|
||||
@@ -105,39 +276,43 @@ class TxMetaController extends ChangeNotifier {
|
||||
return setRecurring(tx, !metaFor(tx).recurring);
|
||||
}
|
||||
|
||||
/// Search description, notes, category label, and date.
|
||||
/// Optional [categories] require a matching category when non-empty.
|
||||
/// Search description, notes, label name, and date.
|
||||
List<BankTransaction> search(
|
||||
List<BankTransaction> txs,
|
||||
String query, {
|
||||
Set<TxCategory> categories = const {},
|
||||
Set<String> labelIds = const {},
|
||||
}) {
|
||||
final needle = query.trim().toLowerCase();
|
||||
final hasText = needle.isNotEmpty;
|
||||
final hasCats = categories.isNotEmpty;
|
||||
if (!hasText && !hasCats) return txs;
|
||||
final hasLabels = labelIds.isNotEmpty;
|
||||
if (!hasText && !hasLabels) return txs;
|
||||
|
||||
return txs.where((tx) {
|
||||
final meta = metaFor(tx);
|
||||
if (hasCats && !categories.contains(meta.category)) return false;
|
||||
if (hasLabels && !meta.labelIds.any(labelIds.contains)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasText) return true;
|
||||
final labelNames = labelsFor(meta).map((l) => l.name.toLowerCase());
|
||||
return tx.description.toLowerCase().contains(needle) ||
|
||||
meta.notes.toLowerCase().contains(needle) ||
|
||||
meta.category.label.toLowerCase().contains(needle) ||
|
||||
labelNames.any((name) => name.contains(needle)) ||
|
||||
tx.date.toLowerCase().contains(needle) ||
|
||||
tx.amount.toString().contains(needle);
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
/// Categories currently used by [transactions] (excluding uncategorized).
|
||||
List<TxCategory> usedCategories(List<BankTransaction> transactions) {
|
||||
final found = <TxCategory>{};
|
||||
/// Labels currently used by [transactions].
|
||||
List<TxLabel> usedLabels(List<BankTransaction> transactions) {
|
||||
final found = <String>{};
|
||||
for (final tx in transactions) {
|
||||
final category = metaFor(tx).category;
|
||||
if (category != TxCategory.uncategorized) found.add(category);
|
||||
found.addAll(metaFor(tx).labelIds);
|
||||
}
|
||||
final list = found.toList()
|
||||
..sort((a, b) => a.label.compareTo(b.label));
|
||||
final list = [
|
||||
for (final label in allLabels)
|
||||
if (found.contains(label.id)) label,
|
||||
];
|
||||
list.sort((a, b) => a.name.compareTo(b.name));
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -146,40 +321,62 @@ class TxMetaController extends ChangeNotifier {
|
||||
await _storage.saveTxMetaStore(
|
||||
TxMetaStoreData(
|
||||
byId: _byId,
|
||||
categoryByDescription: _categoryByDescription,
|
||||
labelsByMerchant: _labelsByMerchant,
|
||||
notesByMerchant: _notesByMerchant,
|
||||
exactTemplates: _exactTemplates,
|
||||
customLabels: _customLabels,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Absolute spend (outflows only) grouped by category.
|
||||
Map<TxCategory, double> spendByCategory(List<BankTransaction> txs) {
|
||||
final totals = <TxCategory, double>{};
|
||||
/// Absolute spend (outflows) per label. Multi-label txs count fully in each.
|
||||
Map<String, double> spendByLabel(List<BankTransaction> txs) {
|
||||
final totals = <String, double>{};
|
||||
for (final tx in txs) {
|
||||
if (tx.amount >= 0) continue;
|
||||
final category = metaFor(tx).category;
|
||||
totals[category] = (totals[category] ?? 0) + tx.amount.abs();
|
||||
final ids = metaFor(tx).labelIds;
|
||||
if (ids.isEmpty) continue;
|
||||
final amount = tx.amount.abs();
|
||||
for (final id in ids) {
|
||||
totals[id] = (totals[id] ?? 0) + amount;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/// Absolute spend grouped by calendar month key `yyyy-MM`.
|
||||
Map<String, double> spendByMonth(List<BankTransaction> txs) {
|
||||
final totals = <String, double>{};
|
||||
/// Unique labeled outflow total (no double-count for multi-label txs).
|
||||
double labeledSpendTotal(List<BankTransaction> txs) {
|
||||
var total = 0.0;
|
||||
for (final tx in txs) {
|
||||
if (tx.amount >= 0) continue;
|
||||
final key = _monthKey(tx.date);
|
||||
if (key == null) continue;
|
||||
totals[key] = (totals[key] ?? 0) + tx.amount.abs();
|
||||
if (!metaFor(tx).hasLabel) continue;
|
||||
total += tx.amount.abs();
|
||||
}
|
||||
final keys = totals.keys.toList()..sort();
|
||||
return {for (final key in keys) key: totals[key]!};
|
||||
return total;
|
||||
}
|
||||
|
||||
String? _monthKey(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) return null;
|
||||
final m = parsed.month.toString().padLeft(2, '0');
|
||||
return '${parsed.year}-$m';
|
||||
/// Absolute spend for each day in [month] (`yyyy-MM-dd` → amount).
|
||||
Map<String, double> spendByDayInMonth(
|
||||
List<BankTransaction> txs,
|
||||
DateTime month,
|
||||
) {
|
||||
final start = DateTime(month.year, month.month, 1);
|
||||
final end = DateTime(month.year, month.month + 1, 0);
|
||||
final totals = <String, double>{};
|
||||
for (var day = 1; day <= end.day; day++) {
|
||||
final key =
|
||||
'${start.year}-${start.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
totals[key] = 0;
|
||||
}
|
||||
for (final tx in txs) {
|
||||
if (tx.amount >= 0) continue;
|
||||
final parsed = DateTime.tryParse(tx.date);
|
||||
if (parsed == null) continue;
|
||||
if (parsed.year != month.year || parsed.month != month.month) continue;
|
||||
final key =
|
||||
'${parsed.year}-${parsed.month.toString().padLeft(2, '0')}-${parsed.day.toString().padLeft(2, '0')}';
|
||||
totals[key] = (totals[key] ?? 0) + tx.amount.abs();
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/connect_bank.dart';
|
||||
@@ -96,7 +95,7 @@ class _HomeBody extends StatelessWidget {
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final recent = bank.transactions.take(5).toList(growable: false);
|
||||
final categorized = bank.transactions
|
||||
.where((tx) => meta.metaFor(tx).hasCategory)
|
||||
.where((tx) => meta.metaFor(tx).hasLabel)
|
||||
.length;
|
||||
|
||||
return ListView(
|
||||
@@ -207,9 +206,16 @@ class _HomeBody extends StatelessWidget {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
meta.metaFor(tx).category.icon,
|
||||
color: meta.metaFor(tx).category.color,
|
||||
leading: Builder(
|
||||
builder: (context) {
|
||||
final labels =
|
||||
meta.labelsFor(meta.metaFor(tx));
|
||||
final label = labels.isEmpty ? null : labels.first;
|
||||
return Icon(
|
||||
label?.icon ?? Icons.receipt_long_outlined,
|
||||
color: label?.color,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
tx.description,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/// Transactions that share a given label.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import 'transaction_detail_screen.dart';
|
||||
|
||||
class LabelTransactionsScreen extends StatelessWidget {
|
||||
const LabelTransactionsScreen({super.key, required this.label});
|
||||
|
||||
final TxLabel label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final txs = [
|
||||
for (final tx in bank.transactions)
|
||||
if (meta.metaFor(tx).labelIds.contains(label.id)) tx,
|
||||
];
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final currency = bank.balance?.currency ??
|
||||
(txs.isNotEmpty ? txs.first.currency : 'EUR');
|
||||
var totalOut = 0.0;
|
||||
for (final tx in txs) {
|
||||
if (tx.amount < 0) totalOut += tx.amount.abs();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(label.name),
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(label.icon, color: label.color),
|
||||
title: Text('${txs.length} transactions'),
|
||||
subtitle: Text('Money out ${formatMoney(totalOut, currency)}'),
|
||||
),
|
||||
const Divider(),
|
||||
if (txs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
'No transactions with this label.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final tx in txs)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
tx.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(formatIsoDate(tx.date)),
|
||||
trailing: Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: tx.amount >= 0 ? colors.primary : colors.error,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => TransactionDetailScreen(tx: tx),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import '../utils/tx_calendar.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import 'label_transactions_screen.dart';
|
||||
|
||||
enum _StatsMode { category, month }
|
||||
|
||||
@@ -29,16 +30,32 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
_StatsMode _mode = _StatsMode.category;
|
||||
var _lookAheadEnabled = false;
|
||||
DateTime _lookAheadMonth = monthStart(DateTime.now());
|
||||
DateTime _chartMonth = monthStart(DateTime.now());
|
||||
|
||||
void _goLookAheadMonth(int delta) {
|
||||
final next = addMonths(_lookAheadMonth, delta);
|
||||
final now = monthStart(DateTime.now());
|
||||
// Look ahead can go forward; still allow browsing past months.
|
||||
if (delta < 0 && next.isBefore(addMonths(now, -24))) return;
|
||||
if (delta > 0 && next.isAfter(addMonths(now, 36))) return;
|
||||
setState(() => _lookAheadMonth = next);
|
||||
}
|
||||
|
||||
void _goChartMonth(int delta) {
|
||||
final next = addMonths(_chartMonth, delta);
|
||||
final now = monthStart(DateTime.now());
|
||||
if (delta > 0 && next.isAfter(now)) return;
|
||||
if (delta < 0 && next.isBefore(addMonths(now, -36))) return;
|
||||
setState(() => _chartMonth = next.isAfter(now) ? now : next);
|
||||
}
|
||||
|
||||
void _openLabel(TxLabel label) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LabelTransactionsScreen(label: label),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
@@ -92,9 +109,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
? bank.transactions.first.currency
|
||||
: 'EUR');
|
||||
final currentBalance = bank.balance?.amount ?? 0;
|
||||
final byCategory = meta.spendByCategory(bank.transactions);
|
||||
final byMonth = meta.spendByMonth(bank.transactions);
|
||||
final totalSpend = byCategory.values.fold<double>(0, (sum, v) => sum + v);
|
||||
final byLabel = meta.spendByLabel(bank.transactions);
|
||||
final byDay = meta.spendByDayInMonth(bank.transactions, _chartMonth);
|
||||
final totalSpend = meta.labeledSpendTotal(bank.transactions);
|
||||
|
||||
final recurrings = recurringItemsFrom(
|
||||
transactions: bank.transactions,
|
||||
@@ -214,10 +231,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
for (final row in projected.take(12))
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Icon(
|
||||
row.item.category.icon,
|
||||
color: row.item.category.color,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
leading: Builder(
|
||||
builder: (context) {
|
||||
final label = row.item.labelIds.isEmpty
|
||||
? null
|
||||
: meta.labelById(row.item.labelIds.first);
|
||||
return Icon(
|
||||
label?.icon ?? Icons.autorenew,
|
||||
color: label?.color,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
row.item.description,
|
||||
@@ -247,7 +272,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Text('Money out (categorized)', style: theme.textTheme.labelMedium),
|
||||
Text('Money out (labeled)', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatMoney(totalSpend, currency),
|
||||
@@ -258,13 +283,13 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _StatsMode.category,
|
||||
label: Text('Category'),
|
||||
label: Text('Label'),
|
||||
icon: Icon(Icons.label_outline),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _StatsMode.month,
|
||||
label: Text('By date'),
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
label: Text('By day'),
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
),
|
||||
],
|
||||
selected: {_mode},
|
||||
@@ -272,7 +297,33 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
setState(() => _mode = selection.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_mode == _StatsMode.month) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Previous month',
|
||||
onPressed: () => _goChartMonth(-1),
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
formatMonthYear(_chartMonth),
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Next month',
|
||||
onPressed: _chartMonth.isBefore(nowMonth)
|
||||
? () => _goChartMonth(1)
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: bank.transactions.isEmpty
|
||||
@@ -283,21 +334,27 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
),
|
||||
)
|
||||
: _mode == _StatsMode.category
|
||||
? _CategoryBars(data: byCategory, currency: currency)
|
||||
: _MonthBars(data: byMonth, currency: currency),
|
||||
? _LabelBars(
|
||||
data: byLabel,
|
||||
resolve: meta.labelById,
|
||||
currency: currency,
|
||||
onBarTap: _openLabel,
|
||||
)
|
||||
: _DayBars(data: byDay, currency: currency),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (_mode == _StatsMode.category)
|
||||
..._categoryLegend(context, byCategory, currency)
|
||||
..._labelLegend(context, byLabel, meta, currency)
|
||||
else
|
||||
..._monthLegend(context, byMonth, currency),
|
||||
..._dayLegend(context, byDay, currency),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _categoryLegend(
|
||||
List<Widget> _labelLegend(
|
||||
BuildContext context,
|
||||
Map<TxCategory, double> data,
|
||||
Map<String, double> data,
|
||||
TxMetaController meta,
|
||||
String currency,
|
||||
) {
|
||||
final entries = data.entries.toList()
|
||||
@@ -305,38 +362,46 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
if (entries.isEmpty) {
|
||||
return [
|
||||
Text(
|
||||
'No outflows yet. Categorize spending from the transaction list.',
|
||||
'No labeled outflows yet. Add a label from a transaction.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final entry in entries)
|
||||
ListTile(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final label = meta.labelById(entry.key);
|
||||
if (label == null) return const SizedBox.shrink();
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: Icon(entry.key.icon, color: entry.key.color),
|
||||
title: Text(entry.key.label),
|
||||
leading: Icon(label.icon, color: label.color),
|
||||
title: Text(label.name),
|
||||
trailing: Text(formatMoney(entry.value, currency)),
|
||||
onTap: () => _openLabel(label),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _monthLegend(
|
||||
List<Widget> _dayLegend(
|
||||
BuildContext context,
|
||||
Map<String, double> data,
|
||||
String currency,
|
||||
) {
|
||||
if (data.isEmpty) {
|
||||
final spent = data.entries.where((e) => e.value > 0).toList().reversed;
|
||||
if (spent.isEmpty) {
|
||||
return [
|
||||
Text(
|
||||
'No dated outflows to chart yet.',
|
||||
'No outflows this month.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
for (final entry in data.entries.toList().reversed)
|
||||
for (final entry in spent)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
@@ -348,20 +413,31 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryBars extends StatelessWidget {
|
||||
const _CategoryBars({required this.data, required this.currency});
|
||||
class _LabelBars extends StatelessWidget {
|
||||
const _LabelBars({
|
||||
required this.data,
|
||||
required this.resolve,
|
||||
required this.currency,
|
||||
required this.onBarTap,
|
||||
});
|
||||
|
||||
final Map<TxCategory, double> data;
|
||||
final Map<String, double> data;
|
||||
final TxLabel? Function(String? id) resolve;
|
||||
final String currency;
|
||||
final void Function(TxLabel label) onBarTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = data.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
final entries = <({TxLabel label, double value})>[
|
||||
for (final entry in data.entries)
|
||||
if (resolve(entry.key) != null)
|
||||
(label: resolve(entry.key)!, value: entry.value),
|
||||
]..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Categorize transactions to see this chart',
|
||||
'Label transactions to see this chart',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -373,11 +449,19 @@ class _CategoryBars extends StatelessWidget {
|
||||
BarChartData(
|
||||
maxY: maxY * 1.15,
|
||||
barTouchData: BarTouchData(
|
||||
touchCallback: (event, response) {
|
||||
if (!event.isInterestedForInteractions) return;
|
||||
final index = response?.spot?.touchedBarGroupIndex;
|
||||
if (index == null || index < 0 || index >= entries.length) return;
|
||||
if (event is FlTapUpEvent) {
|
||||
onBarTap(entries[index].label);
|
||||
}
|
||||
},
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final entry = entries[group.x.toInt()];
|
||||
return BarTooltipItem(
|
||||
'${entry.key.label}\n${formatMoney(entry.value, currency)}',
|
||||
'${entry.label.name}\n${formatMoney(entry.value, currency)}',
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onInverseSurface,
|
||||
),
|
||||
@@ -399,7 +483,7 @@ class _CategoryBars extends StatelessWidget {
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final label = entries[index].key.label;
|
||||
final label = entries[index].label.name;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
@@ -423,7 +507,7 @@ class _CategoryBars extends StatelessWidget {
|
||||
width: 18,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
color: entries[i].key.color,
|
||||
color: entries[i].label.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -433,8 +517,8 @@ class _CategoryBars extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthBars extends StatelessWidget {
|
||||
const _MonthBars({required this.data, required this.currency});
|
||||
class _DayBars extends StatelessWidget {
|
||||
const _DayBars({required this.data, required this.currency});
|
||||
|
||||
final Map<String, double> data;
|
||||
final String currency;
|
||||
@@ -442,26 +526,30 @@ class _MonthBars extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = data.entries.toList();
|
||||
if (entries.isEmpty) {
|
||||
if (entries.every((e) => e.value == 0)) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No monthly spend yet',
|
||||
'No spend this month',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final maxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
||||
final maxY = entries.map((e) => e.value).fold<double>(0, (a, b) => a > b ? a : b);
|
||||
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.15,
|
||||
maxY: safeMax,
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final entry = entries[group.x.toInt()];
|
||||
final day = entry.key.length >= 10
|
||||
? entry.key.substring(8)
|
||||
: entry.key;
|
||||
return BarTooltipItem(
|
||||
'${entry.key}\n${formatMoney(entry.value, currency)}',
|
||||
'Day $day\n${formatMoney(entry.value, currency)}',
|
||||
TextStyle(color: colors.onInverseSurface),
|
||||
);
|
||||
},
|
||||
@@ -476,17 +564,20 @@ class _MonthBars extends StatelessWidget {
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: entries.length > 16 ? 5 : 2,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index < 0 || index >= entries.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final key = entries[index].key;
|
||||
final short = key.length >= 7 ? key.substring(5) : key;
|
||||
if (entries.length > 16 && index % 5 != 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final day = (index + 1).toString();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
short,
|
||||
day,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
@@ -503,10 +594,12 @@ class _MonthBars extends StatelessWidget {
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: entries[i].value,
|
||||
width: 22,
|
||||
width: entries.length > 20 ? 6 : 8,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
color: colors.primary,
|
||||
const BorderRadius.vertical(top: Radius.circular(3)),
|
||||
color: entries[i].value > 0
|
||||
? colors.primary
|
||||
: colors.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -92,9 +92,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.open_in_browser_outlined),
|
||||
title: Text('Enable Banking redirect'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.open_in_browser_outlined),
|
||||
title: const Text('Enable Banking redirect'),
|
||||
subtitle: Text(kEnableBankingRedirectUri),
|
||||
),
|
||||
],
|
||||
@@ -104,9 +104,8 @@ class SettingsScreen extends StatelessWidget {
|
||||
Text(
|
||||
'Enable Banking only allows http(s) redirects. Keep '
|
||||
'$kEnableBankingRedirectUri in the Control Panel and backend '
|
||||
'REDIRECT_URI. The app captures that URL inside a WebView. '
|
||||
'Override the API host with '
|
||||
'`--dart-define=API_BASE_URL=http://<host>:8000`.',
|
||||
'REDIRECT_URI. The app captures that callback path in a WebView. '
|
||||
'Override with `--dart-define=API_BASE_URL=https://…`.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Transaction detail: category, recurring, and user notes.
|
||||
/// Transaction detail: label, recurring, notes, apply-to-similar.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../providers/bank_provider.dart';
|
||||
import '../providers/tx_meta_provider.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
@@ -23,12 +24,18 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
var _dirty = false;
|
||||
late bool _applyToSimilar;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final notes = context.read<TxMetaController>().metaFor(widget.tx).notes;
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(widget.tx, all);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -37,25 +44,116 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<BankTransaction> _similarScope(TxMetaController meta) {
|
||||
final all = context.read<BankController>().transactions;
|
||||
if (!_applyToSimilar) return const [];
|
||||
return meta.similarTo(widget.tx, all);
|
||||
}
|
||||
|
||||
Future<void> _saveNotes() async {
|
||||
await context.read<TxMetaController>().setNotes(
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.setNotes(
|
||||
widget.tx,
|
||||
_notesController.text,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _dirty = false);
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Notes saved')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
count == 1
|
||||
? 'Notes saved'
|
||||
: 'Notes saved on $count similar transactions',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleLabel(String labelId) async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.toggleLabel(
|
||||
widget.tx,
|
||||
labelId,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final count = similar.isEmpty ? 1 : similar.length;
|
||||
if (count > 1) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Labels updated on $count similar transactions'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearLabels() async {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final similar = _similarScope(meta);
|
||||
await meta.clearLabels(
|
||||
widget.tx,
|
||||
applyToSimilar: similar,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createLabel() async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const _NewLabelDialog(),
|
||||
);
|
||||
if (name == null || name.isEmpty || !mounted) return;
|
||||
final meta = context.read<TxMetaController>();
|
||||
final label = await meta.createLabel(name);
|
||||
if (!mounted) return;
|
||||
await _toggleLabel(label.id);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveLabel(TxLabel label) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Remove “${label.name}”?'),
|
||||
content: const Text(
|
||||
'This deletes the label. Transactions keep any other labels.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
await context.read<TxMetaController>().removeLabel(label.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tx = widget.tx;
|
||||
final meta = context.watch<TxMetaController>().metaFor(tx);
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final meta = metaController.metaFor(tx);
|
||||
final all = context.watch<BankController>().transactions;
|
||||
final similarCount = metaController.countSimilar(tx, all);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final selectedIds = meta.labelIds.toSet();
|
||||
final merchantPreview = tx.merchantKey.isEmpty
|
||||
? tx.description
|
||||
: (tx.merchantKey.length > 48
|
||||
? '${tx.merchantKey.substring(0, 47)}…'
|
||||
: tx.merchantKey);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -86,7 +184,20 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (similarCount > 1) ...[
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Apply to all similar'),
|
||||
subtitle: Text(
|
||||
'$similarCount matches · $merchantPreview',
|
||||
),
|
||||
value: _applyToSimilar,
|
||||
onChanged: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
@@ -98,24 +209,38 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Category', style: theme.textTheme.titleSmall),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
if (meta.hasLabel)
|
||||
TextButton(
|
||||
onPressed: _clearLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in TxCategory.values)
|
||||
ChoiceChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
for (final label in metaController.allLabels)
|
||||
GestureDetector(
|
||||
onLongPress: label.custom
|
||||
? () => _confirmRemoveLabel(label)
|
||||
: null,
|
||||
child: FilterChip(
|
||||
avatar: Icon(label.icon, size: 16, color: label.color),
|
||||
label: Text(label.name),
|
||||
selected: selectedIds.contains(label.id),
|
||||
onSelected: (_) => _toggleLabel(label.id),
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: meta.category == category,
|
||||
onSelected: (_) {
|
||||
context.read<TxMetaController>().setCategory(tx, category);
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New label'),
|
||||
onPressed: _createLabel,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -137,12 +262,17 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty ? _saveNotes : null,
|
||||
child: const Text('Save notes'),
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Matching descriptions reuse this category automatically. '
|
||||
'Exact same amount + description also copies notes.',
|
||||
'Tap labels to add or remove several. Long-press a custom label '
|
||||
'to delete it. Similar matches strip dates and ids so supermarket '
|
||||
'purchases group together.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
@@ -151,3 +281,54 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewLabelDialog extends StatefulWidget {
|
||||
const _NewLabelDialog();
|
||||
|
||||
@override
|
||||
State<_NewLabelDialog> createState() => _NewLabelDialogState();
|
||||
}
|
||||
|
||||
class _NewLabelDialogState extends State<_NewLabelDialog> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
Navigator.pop(context, _controller.text.trim());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New label'),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(hintText: 'e.g. Supermarket'),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
DateTime _month = monthStart(DateTime.now());
|
||||
DateTime? _selectedDay;
|
||||
var _searchInMonthOnly = false;
|
||||
final Set<TxCategory> _categoryFilters = {};
|
||||
final Set<String> _labelFilters = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -40,7 +40,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
}
|
||||
|
||||
bool get _isSearching =>
|
||||
_query.trim().isNotEmpty || _categoryFilters.isNotEmpty;
|
||||
_query.trim().isNotEmpty || _labelFilters.isNotEmpty;
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
@@ -67,15 +67,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
return isSameMonth(date, _month);
|
||||
}
|
||||
|
||||
Future<void> _pickCategories(List<TxCategory> available) async {
|
||||
Future<void> _pickLabels(List<TxLabel> available) async {
|
||||
if (available.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No categories in use yet')),
|
||||
const SnackBar(content: Text('No labels in use yet')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = Set<TxCategory>.of(_categoryFilters);
|
||||
final selected = Set<String>.of(_labelFilters);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
@@ -90,7 +90,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Filter by category',
|
||||
'Filter by label',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -98,21 +98,21 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in available)
|
||||
for (final label in available)
|
||||
FilterChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
label.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
color: label.color,
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: selected.contains(category),
|
||||
label: Text(label.name),
|
||||
selected: selected.contains(label.id),
|
||||
onSelected: (value) {
|
||||
setSheetState(() {
|
||||
if (value) {
|
||||
selected.add(category);
|
||||
selected.add(label.id);
|
||||
} else {
|
||||
selected.remove(category);
|
||||
selected.remove(label.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -123,7 +123,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_categoryFilters
|
||||
_labelFilters
|
||||
..clear()
|
||||
..addAll(selected);
|
||||
});
|
||||
@@ -241,7 +241,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
final filtered = metaController.search(
|
||||
searchPool,
|
||||
_query,
|
||||
categories: _categoryFilters,
|
||||
labelIds: _labelFilters,
|
||||
);
|
||||
|
||||
var moneyOut = 0.0;
|
||||
@@ -257,7 +257,10 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
(bank.transactions.isNotEmpty
|
||||
? bank.transactions.first.currency
|
||||
: 'EUR');
|
||||
final usedCategories = metaController.usedCategories(bank.transactions);
|
||||
final usedLabels = metaController.usedLabels(bank.transactions);
|
||||
final labelById = {
|
||||
for (final label in metaController.allLabels) label.id: label,
|
||||
};
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
@@ -274,13 +277,11 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search description, notes…',
|
||||
prefixIcon: IconButton(
|
||||
tooltip: 'Categories',
|
||||
onPressed: () => _pickCategories(usedCategories),
|
||||
tooltip: 'Labels',
|
||||
onPressed: () => _pickLabels(usedLabels),
|
||||
icon: Icon(
|
||||
Icons.search,
|
||||
color: _categoryFilters.isEmpty
|
||||
? null
|
||||
: colors.primary,
|
||||
color: _labelFilters.isEmpty ? null : colors.primary,
|
||||
),
|
||||
),
|
||||
suffixIcon: _query.isEmpty
|
||||
@@ -316,19 +317,24 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_categoryFilters.isNotEmpty)
|
||||
if (_labelFilters.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in _categoryFilters)
|
||||
for (final id in _labelFilters)
|
||||
if (labelById[id] != null)
|
||||
InputChip(
|
||||
avatar: Icon(category.icon, size: 16, color: category.color),
|
||||
label: Text(category.label),
|
||||
avatar: Icon(
|
||||
labelById[id]!.icon,
|
||||
size: 16,
|
||||
color: labelById[id]!.color,
|
||||
),
|
||||
label: Text(labelById[id]!.name),
|
||||
onDeleted: () {
|
||||
setState(() => _categoryFilters.remove(category));
|
||||
setState(() => _labelFilters.remove(id));
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -411,6 +417,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_CompactTransactionTile(
|
||||
tx: filtered[i],
|
||||
meta: metaController.metaFor(filtered[i]),
|
||||
labels: metaController
|
||||
.labelsFor(metaController.metaFor(filtered[i])),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -467,16 +475,21 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
const _CompactTransactionTile({
|
||||
required this.tx,
|
||||
required this.meta,
|
||||
this.labels = const [],
|
||||
});
|
||||
|
||||
final BankTransaction tx;
|
||||
final TxMeta meta;
|
||||
final List<TxLabel> labels;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
final primary = labels.isEmpty ? null : labels.first;
|
||||
final iconColor = primary?.color ?? colors.outline;
|
||||
final iconData = primary?.icon ?? Icons.receipt_long_outlined;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -494,13 +507,13 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.category.color.withValues(alpha: 0.14),
|
||||
color: iconColor.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
meta.category.icon,
|
||||
iconData,
|
||||
size: 18,
|
||||
color: meta.category.color,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -521,7 +534,8 @@ class _CompactTransactionTile extends StatelessWidget {
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (meta.hasCategory) meta.category.label,
|
||||
if (labels.isNotEmpty)
|
||||
labels.map((l) => l.name).join(', '),
|
||||
if (meta.recurring) 'Recurring',
|
||||
if (meta.hasNotes) 'Note',
|
||||
if (tx.pending) 'Pending',
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/bank.dart';
|
||||
import '../models/transaction_meta.dart';
|
||||
import '../utils/constants.dart';
|
||||
|
||||
@@ -12,13 +13,81 @@ import '../utils/constants.dart';
|
||||
class TxMetaStoreData {
|
||||
const TxMetaStoreData({
|
||||
this.byId = const {},
|
||||
this.categoryByDescription = const {},
|
||||
this.labelsByMerchant = const {},
|
||||
this.notesByMerchant = const {},
|
||||
this.exactTemplates = const {},
|
||||
this.customLabels = const [],
|
||||
});
|
||||
|
||||
final Map<String, TxMeta> byId;
|
||||
final Map<String, TxCategory> categoryByDescription;
|
||||
final Map<String, List<String>> labelsByMerchant;
|
||||
final Map<String, String> notesByMerchant;
|
||||
final Map<String, TxExactTemplate> exactTemplates;
|
||||
final List<TxLabel> customLabels;
|
||||
}
|
||||
|
||||
/// Cached bank payload so the app stays usable across restarts / brief outages.
|
||||
class BankCache {
|
||||
const BankCache({
|
||||
required this.status,
|
||||
this.balance,
|
||||
this.transactions = const [],
|
||||
});
|
||||
|
||||
final ConnectStatus status;
|
||||
final BankBalance? balance;
|
||||
final List<BankTransaction> transactions;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'status': {
|
||||
'status': status.status,
|
||||
'linked': status.linked,
|
||||
'accountId': status.accountId,
|
||||
'sessionId': status.sessionId,
|
||||
'state': status.state,
|
||||
'validUntil': status.validUntil,
|
||||
},
|
||||
'balance': balance == null
|
||||
? null
|
||||
: {
|
||||
'amount': balance!.amount,
|
||||
'currency': balance!.currency,
|
||||
'balanceType': balance!.balanceType,
|
||||
},
|
||||
'transactions': [
|
||||
for (final tx in transactions)
|
||||
{
|
||||
'date': tx.date,
|
||||
'description': tx.description,
|
||||
'amount': tx.amount,
|
||||
'currency': tx.currency,
|
||||
'pending': tx.pending,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
factory BankCache.fromJson(Map<String, dynamic> json) {
|
||||
final statusRaw = json['status'];
|
||||
final status = statusRaw is Map
|
||||
? ConnectStatus.fromJson(Map<String, dynamic>.from(statusRaw))
|
||||
: ConnectStatus.disconnected;
|
||||
final balanceRaw = json['balance'];
|
||||
final balance = balanceRaw is Map
|
||||
? BankBalance.fromJson(Map<String, dynamic>.from(balanceRaw))
|
||||
: null;
|
||||
final txsRaw = json['transactions'];
|
||||
final transactions = txsRaw is List
|
||||
? txsRaw
|
||||
.whereType<Map>()
|
||||
.map((e) => BankTransaction.fromJson(Map<String, dynamic>.from(e)))
|
||||
.toList(growable: false)
|
||||
: const <BankTransaction>[];
|
||||
return BankCache(
|
||||
status: status,
|
||||
balance: balance,
|
||||
transactions: transactions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads and stores user preferences. Call [init] once at startup.
|
||||
@@ -52,34 +121,54 @@ class StorageService {
|
||||
return _prefs.setBool(StorageKeys.useRemoteData, value);
|
||||
}
|
||||
|
||||
BankCache? loadBankCache() {
|
||||
final raw = _prefs.getString(StorageKeys.bankCache);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return null;
|
||||
return BankCache.fromJson(Map<String, dynamic>.from(decoded));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveBankCache(BankCache cache) {
|
||||
return _prefs.setString(StorageKeys.bankCache, jsonEncode(cache.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clearBankCache() {
|
||||
return _prefs.remove(StorageKeys.bankCache);
|
||||
}
|
||||
|
||||
TxMetaStoreData loadTxMetaStore() {
|
||||
final raw = _prefs.getString(StorageKeys.txMeta);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
return _decodeStore(raw);
|
||||
}
|
||||
|
||||
// Migrate flat v1 map → v2 store.
|
||||
final legacy = _prefs.getString(StorageKeys.txMetaLegacy);
|
||||
if (legacy == null || legacy.isEmpty) return const TxMetaStoreData();
|
||||
try {
|
||||
final decoded = jsonDecode(legacy);
|
||||
if (decoded is! Map) return const TxMetaStoreData();
|
||||
final byId = <String, TxMeta>{};
|
||||
final categoryByDescription = <String, TxCategory>{};
|
||||
final labelsByMerchant = <String, List<String>>{};
|
||||
decoded.forEach((key, value) {
|
||||
if (key is! String || value is! Map) return;
|
||||
final meta = TxMeta.fromJson(Map<String, dynamic>.from(value));
|
||||
byId[key] = meta;
|
||||
if (meta.hasCategory) {
|
||||
if (meta.hasLabel) {
|
||||
final desc = _descriptionFromLegacyId(key);
|
||||
if (desc != null) {
|
||||
categoryByDescription[desc] = meta.category;
|
||||
labelsByMerchant[merchantKeyFromDescription(desc)] =
|
||||
List<String>.of(meta.labelIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
return TxMetaStoreData(
|
||||
byId: byId,
|
||||
categoryByDescription: categoryByDescription,
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
);
|
||||
} catch (_) {
|
||||
return const TxMetaStoreData();
|
||||
@@ -91,31 +180,30 @@ class StorageService {
|
||||
'byId': {
|
||||
for (final e in store.byId.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'categoryByDescription': {
|
||||
for (final e in store.categoryByDescription.entries)
|
||||
e.key: e.value.name,
|
||||
'labelsByMerchant': {
|
||||
for (final e in store.labelsByMerchant.entries) e.key: e.value,
|
||||
},
|
||||
// Legacy single-label map for older builds.
|
||||
'labelByMerchant': {
|
||||
for (final e in store.labelsByMerchant.entries)
|
||||
if (e.value.isNotEmpty) e.key: e.value.first,
|
||||
},
|
||||
'categoryByDescription': {
|
||||
for (final e in store.labelsByMerchant.entries)
|
||||
if (e.value.isNotEmpty) e.key: e.value.first,
|
||||
},
|
||||
'notesByMerchant': store.notesByMerchant,
|
||||
'notesByDescription': store.notesByMerchant,
|
||||
'exactTemplates': {
|
||||
for (final e in store.exactTemplates.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'customLabels': [
|
||||
for (final label in store.customLabels) label.toJson(),
|
||||
],
|
||||
};
|
||||
return _prefs.setString(StorageKeys.txMeta, jsonEncode(encoded));
|
||||
}
|
||||
|
||||
/// Deprecated shim used by older call sites during transition.
|
||||
Map<String, TxMeta> loadTxMeta() => loadTxMetaStore().byId;
|
||||
|
||||
Future<void> saveTxMeta(Map<String, TxMeta> meta) {
|
||||
final current = loadTxMetaStore();
|
||||
return saveTxMetaStore(
|
||||
TxMetaStoreData(
|
||||
byId: meta,
|
||||
categoryByDescription: current.categoryByDescription,
|
||||
exactTemplates: current.exactTemplates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TxMetaStoreData _decodeStore(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
@@ -132,12 +220,46 @@ class StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
final categoryByDescription = <String, TxCategory>{};
|
||||
final cats = root['categoryByDescription'];
|
||||
if (cats is Map) {
|
||||
cats.forEach((key, value) {
|
||||
if (key is String && value is String) {
|
||||
categoryByDescription[key] = TxCategoryX.fromName(value);
|
||||
final labelsByMerchant = <String, List<String>>{};
|
||||
final multi = root['labelsByMerchant'];
|
||||
if (multi is Map) {
|
||||
multi.forEach((key, value) {
|
||||
if (key is! String) return;
|
||||
if (value is List) {
|
||||
final ids = [
|
||||
for (final item in value)
|
||||
if (item is String &&
|
||||
item.isNotEmpty &&
|
||||
item != 'uncategorized' &&
|
||||
item != 'other')
|
||||
item,
|
||||
];
|
||||
if (ids.isNotEmpty) labelsByMerchant[key] = ids;
|
||||
} else if (value is String &&
|
||||
value.isNotEmpty &&
|
||||
value != 'uncategorized' &&
|
||||
value != 'other') {
|
||||
labelsByMerchant[key] = [value];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
final singles =
|
||||
root['labelByMerchant'] ?? root['categoryByDescription'];
|
||||
if (singles is Map) {
|
||||
singles.forEach((key, value) {
|
||||
if (key is! String || value is! String) return;
|
||||
if (value == 'uncategorized' || value == 'other') return;
|
||||
labelsByMerchant[key] = [value];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final notesByMerchant = <String, String>{};
|
||||
final notesRaw = root['notesByMerchant'] ?? root['notesByDescription'];
|
||||
if (notesRaw is Map) {
|
||||
notesRaw.forEach((key, value) {
|
||||
if (key is String && value is String && value.trim().isNotEmpty) {
|
||||
notesByMerchant[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -153,10 +275,24 @@ class StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
final customLabels = <TxLabel>[];
|
||||
final customs = root['customLabels'];
|
||||
if (customs is List) {
|
||||
for (final item in customs) {
|
||||
if (item is Map) {
|
||||
customLabels.add(
|
||||
TxLabel.fromJson(Map<String, dynamic>.from(item)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TxMetaStoreData(
|
||||
byId: byId,
|
||||
categoryByDescription: categoryByDescription,
|
||||
labelsByMerchant: labelsByMerchant,
|
||||
notesByMerchant: notesByMerchant,
|
||||
exactTemplates: exactTemplates,
|
||||
customLabels: customLabels,
|
||||
);
|
||||
} catch (_) {
|
||||
return const TxMetaStoreData();
|
||||
|
||||
+15
-12
@@ -13,23 +13,25 @@ const Duration kSplashDuration = Duration(seconds: 2);
|
||||
/// Base URL for the personal ingtrak FastAPI backend.
|
||||
///
|
||||
/// Override at build/run time:
|
||||
/// `flutter run --dart-define=API_BASE_URL=http://192.168.1.114:8000`
|
||||
/// `flutter run --dart-define=API_BASE_URL=https://ingtrak.satoshinakamoto.win`
|
||||
///
|
||||
/// Defaults:
|
||||
/// - Android emulator → `http://10.0.2.2:8000` (host loopback)
|
||||
/// - iOS Simulator → `http://127.0.0.1:8000`
|
||||
/// - Physical device → Mac LAN IP, e.g. `http://192.168.1.114:8000`
|
||||
/// Local defaults (when override omitted):
|
||||
/// - Android emulator → `http://10.0.2.2:8000`
|
||||
/// - Physical device on LAN → `http://192.168.x.x:8000`
|
||||
const String kApiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.0.2.2:8000',
|
||||
defaultValue: 'https://ingtrak.satoshinakamoto.win',
|
||||
);
|
||||
|
||||
/// Enable Banking only accepts http(s) redirect URLs (not custom schemes).
|
||||
/// Keep this exact value in the Control Panel and backend `REDIRECT_URI`.
|
||||
/// The app opens auth in a WebView and intercepts this callback to read `code`.
|
||||
const String kEnableBankingRedirectUri =
|
||||
'http://localhost:8000/api/connect/callback';
|
||||
|
||||
/// Enable Banking redirect derived from [kApiBaseUrl].
|
||||
/// Must match backend `REDIRECT_URI` and the Control Panel whitelist.
|
||||
/// The app opens auth in a WebView and intercepts this callback path for `code`.
|
||||
String get kEnableBankingRedirectUri {
|
||||
final base = kApiBaseUrl.endsWith('/')
|
||||
? kApiBaseUrl.substring(0, kApiBaseUrl.length - 1)
|
||||
: kApiBaseUrl;
|
||||
return '$base/api/connect/callback';
|
||||
}
|
||||
/// Optional app-scheme bounce used after the http callback (not registered
|
||||
/// with Enable Banking).
|
||||
const String kBankRedirectScheme = 'ingtrak';
|
||||
@@ -42,6 +44,7 @@ abstract final class StorageKeys {
|
||||
static const useRemoteData = 'use_remote_data';
|
||||
static const txMeta = 'tx_meta_v2';
|
||||
static const txMetaLegacy = 'tx_meta_v1';
|
||||
static const bankCache = 'bank_cache_v1';
|
||||
}
|
||||
|
||||
/// Named routes registered on [MaterialApp].
|
||||
|
||||
@@ -12,14 +12,14 @@ class RecurringItem {
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.dayOfMonth,
|
||||
required this.category,
|
||||
this.labelIds = const [],
|
||||
});
|
||||
|
||||
final String description;
|
||||
final double amount;
|
||||
final String currency;
|
||||
final int dayOfMonth;
|
||||
final TxCategory category;
|
||||
final List<String> labelIds;
|
||||
}
|
||||
|
||||
/// Build unique recurring templates from annotated transactions.
|
||||
@@ -51,7 +51,7 @@ List<RecurringItem> recurringItemsFrom({
|
||||
amount: tx.amount,
|
||||
currency: tx.currency,
|
||||
dayOfMonth: (DateTime.tryParse(tx.date)?.day ?? 1).clamp(1, 28),
|
||||
category: metaFor(tx).category,
|
||||
labelIds: List<String>.of(metaFor(tx).labelIds),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,19 +41,21 @@ void main() {
|
||||
expect(start.state, 'abc');
|
||||
});
|
||||
|
||||
test('BankTransaction match keys support auto-category and notes', () {
|
||||
test('BankTransaction match keys support auto-label and notes', () {
|
||||
final tx = BankTransaction.fromJson({
|
||||
'date': '2026-09-01',
|
||||
'description': 'Coffee Shop',
|
||||
'description':
|
||||
'BCK*AH CAMPUS DIEMEN ZUID NLD Pasvolgnr: 001 Transactie: C00338123',
|
||||
'amount': -3.5,
|
||||
'currency': 'EUR',
|
||||
'pending': false,
|
||||
});
|
||||
expect(tx.descriptionKey, 'coffee shop');
|
||||
expect(tx.exactKey, contains('coffee shop'));
|
||||
expect(tx.descriptionKey, contains('pasvolgnr'));
|
||||
expect(tx.merchantKey, contains('ah campus diemen'));
|
||||
expect(tx.merchantKey.contains('pasvolgnr'), isFalse);
|
||||
expect(tx.exactKey, contains('-3.5'));
|
||||
expect(formatMoney(tx.amount, tx.currency), contains('EUR'));
|
||||
expect(TxCategory.groceries.label, 'Groceries');
|
||||
expect(kBuiltinLabels.first.name, 'Groceries');
|
||||
});
|
||||
|
||||
test('look-ahead applies monthly recurring amounts', () {
|
||||
@@ -63,14 +65,14 @@ void main() {
|
||||
amount: 2000,
|
||||
currency: 'EUR',
|
||||
dayOfMonth: 25,
|
||||
category: TxCategory.salary,
|
||||
labelIds: ['salary'],
|
||||
),
|
||||
const RecurringItem(
|
||||
description: 'Rent',
|
||||
amount: -900,
|
||||
currency: 'EUR',
|
||||
dayOfMonth: 1,
|
||||
category: TxCategory.mortgage,
|
||||
labelIds: ['mortgage'],
|
||||
),
|
||||
];
|
||||
final from = DateTime(2026, 9, 6);
|
||||
|
||||
Reference in New Issue
Block a user