Files
sure/mobile/lib/models/transaction.dart
ghost 5372a08788 feat(mobile): add transaction metadata editing (#2131)
* feat(mobile): add transaction metadata editing

* fix(mobile): preserve explicit metadata clears

* fix(mobile): derive persisted tag metadata state

* fix(mobile): avoid logging transaction details

* fix(mobile): harden transaction edit sync

Pass the edited transaction context through provider updates, refresh or fall back after empty update responses, surface field-level API errors, and avoid forced metadata refetches on every edit screen open.

* fix(mobile): keep transaction edit selects ci-compatible

Use the DropdownButtonFormField API supported by the Flutter version pinned in upstream mobile CI.
2026-06-04 23:02:57 +02:00

170 lines
5.6 KiB
Dart

class Transaction {
final String? id;
final String accountId;
final String name;
final String date;
final String amount;
final String currency;
final String nature; // "expense" or "income"
final String? notes;
final String? categoryId;
final String? categoryName;
final bool categoryProvided;
final String? merchantId;
final String? merchantName;
final bool merchantProvided;
final List<String> tagIds;
final List<String> tagNames;
final bool tagsProvided;
Transaction({
this.id,
required this.accountId,
required this.name,
required this.date,
required this.amount,
required this.currency,
required this.nature,
this.notes,
this.categoryId,
this.categoryName,
bool? categoryProvided,
this.merchantId,
this.merchantName,
bool? merchantProvided,
List<String> tagIds = const [],
List<String> tagNames = const [],
bool? tagsProvided,
}) : tagIds = List.unmodifiable(tagIds),
tagNames = List.unmodifiable(tagNames),
categoryProvided =
categoryProvided ?? (categoryId != null || categoryName != null),
merchantProvided =
merchantProvided ?? (merchantId != null || merchantName != null),
tagsProvided =
tagsProvided ?? (tagIds.isNotEmpty || tagNames.isNotEmpty);
factory Transaction.fromJson(Map<String, dynamic> json) {
// Handle both API formats:
// 1. New format: {"account": {"id": "xxx", "name": "..."}}
// 2. Old format: {"account_id": "xxx"}
String accountId = '';
if (json['account'] != null && json['account'] is Map) {
accountId = json['account']['id']?.toString() ?? '';
} else if (json['account_id'] != null) {
accountId = json['account_id']?.toString() ?? '';
}
// Handle classification (from backend) or nature (from mobile)
String nature = 'expense';
if (json['classification'] != null) {
final classification =
json['classification']?.toString().toLowerCase() ?? '';
nature = classification == 'income' ? 'income' : 'expense';
} else if (json['nature'] != null) {
nature = json['nature']?.toString() ?? 'expense';
}
// Parse category from API response
String? categoryId;
String? categoryName;
final categoryProvided = json.containsKey('category') ||
json.containsKey('category_id') ||
json.containsKey('category_name');
if (json['category'] != null && json['category'] is Map) {
categoryId = json['category']['id']?.toString();
categoryName = json['category']['name']?.toString();
} else if (json['category_id'] != null) {
categoryId = json['category_id']?.toString();
categoryName = json['category_name']?.toString();
}
String? merchantId;
String? merchantName;
final merchantProvided = json.containsKey('merchant') ||
json.containsKey('merchant_id') ||
json.containsKey('merchant_name');
if (json['merchant'] != null && json['merchant'] is Map) {
merchantId = json['merchant']['id']?.toString();
merchantName = json['merchant']['name']?.toString();
} else if (json['merchant_id'] != null) {
merchantId = json['merchant_id']?.toString();
merchantName = json['merchant_name']?.toString();
}
final tagIds = <String>[];
final tagNames = <String>[];
final tagsProvided = json.containsKey('tags') ||
json.containsKey('tag_ids') ||
json.containsKey('tag_names');
if (json['tags'] is List) {
for (final tag in json['tags']) {
if (tag is Map) {
final id = tag['id']?.toString().trim();
if (id != null && id.isNotEmpty) {
tagIds.add(id);
tagNames.add(tag['name']?.toString() ?? '');
}
}
}
} else if (json['tag_ids'] is List) {
final rawIds = json['tag_ids'] as List;
final rawNames =
json['tag_names'] is List ? json['tag_names'] as List : const [];
for (var i = 0; i < rawIds.length; i++) {
final id = rawIds[i]?.toString().trim() ?? '';
if (id.isNotEmpty) {
tagIds.add(id);
tagNames
.add(i < rawNames.length ? rawNames[i]?.toString() ?? '' : '');
}
}
}
while (tagNames.length < tagIds.length) {
tagNames.add('');
}
return Transaction(
id: json['id']?.toString(),
accountId: accountId,
name: json['name']?.toString() ?? '',
date: json['date']?.toString() ?? '',
amount: json['amount']?.toString() ?? '0',
currency: json['currency']?.toString() ?? '',
nature: nature,
notes: json['notes']?.toString(),
categoryId: categoryId,
categoryName: categoryName,
categoryProvided: categoryProvided,
merchantId: merchantId,
merchantName: merchantName,
merchantProvided: merchantProvided,
tagIds: tagIds,
tagNames: tagNames,
tagsProvided: tagsProvided,
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'account_id': accountId,
'name': name,
'date': date,
'amount': amount,
'currency': currency,
'nature': nature,
if (notes != null) 'notes': notes,
if (categoryId != null) 'category_id': categoryId,
if (categoryName != null) 'category_name': categoryName,
if (merchantId != null) 'merchant_id': merchantId,
if (merchantName != null) 'merchant_name': merchantName,
if (tagIds.isNotEmpty) 'tag_ids': tagIds,
if (tagNames.isNotEmpty) 'tag_names': tagNames,
};
}
bool get isExpense => nature == 'expense';
bool get isIncome => nature == 'income';
}