Files
sure/mobile/lib/models/offline_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

314 lines
9.3 KiB
Dart

import 'dart:convert';
import 'transaction.dart';
enum SyncStatus {
synced, // Transaction is synced with server
pending, // Transaction is waiting to be synced (create)
failed, // Last sync attempt failed
pendingDelete, // Transaction is waiting to be deleted on server
}
class OfflineTransaction extends Transaction {
final String localId;
final SyncStatus syncStatus;
final DateTime createdAt;
final DateTime updatedAt;
OfflineTransaction({
super.id,
required this.localId,
required super.accountId,
required super.name,
required super.date,
required super.amount,
required super.currency,
required super.nature,
super.notes,
super.categoryId,
super.categoryName,
super.categoryProvided = true,
super.merchantId,
super.merchantName,
super.merchantProvided = true,
super.tagIds,
super.tagNames,
super.tagsProvided = true,
this.syncStatus = SyncStatus.pending,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
factory OfflineTransaction.fromTransaction(
Transaction transaction, {
required String localId,
SyncStatus syncStatus = SyncStatus.synced,
}) {
return OfflineTransaction(
id: transaction.id,
localId: localId,
accountId: transaction.accountId,
name: transaction.name,
date: transaction.date,
amount: transaction.amount,
currency: transaction.currency,
nature: transaction.nature,
notes: transaction.notes,
categoryId: transaction.categoryId,
categoryName: transaction.categoryName,
categoryProvided: transaction.categoryProvided,
merchantId: transaction.merchantId,
merchantName: transaction.merchantName,
merchantProvided: transaction.merchantProvided,
tagIds: transaction.tagIds,
tagNames: transaction.tagNames,
tagsProvided: transaction.tagsProvided,
syncStatus: syncStatus,
);
}
factory OfflineTransaction.fromDatabaseMap(Map<String, dynamic> map) {
final tagIds = _decodeStringList(map['tag_ids'] as String?);
final tagNames = _decodeStringList(map['tag_names'] as String?);
final tagsProvided =
map.containsKey('tag_ids') || map.containsKey('tag_names');
return OfflineTransaction(
id: map['server_id'] as String?,
localId: map['local_id'] as String,
accountId: map['account_id'] as String,
name: map['name'] as String,
date: map['date'] as String,
amount: map['amount'] as String,
currency: map['currency'] as String,
nature: map['nature'] as String,
notes: map['notes'] as String?,
categoryId: map['category_id'] as String?,
categoryName: map['category_name'] as String?,
merchantId: map['merchant_id'] as String?,
merchantName: map['merchant_name'] as String?,
tagIds: tagIds,
tagNames: tagNames,
tagsProvided: tagsProvided,
syncStatus: _parseSyncStatus(map['sync_status'] as String),
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
}
Map<String, dynamic> toDatabaseMap() {
return {
'local_id': localId,
'server_id': id,
'account_id': accountId,
'name': name,
'date': date,
'amount': amount,
'currency': currency,
'nature': nature,
'notes': notes,
'category_id': categoryId,
'category_name': categoryName,
'merchant_id': merchantId,
'merchant_name': merchantName,
'tag_ids': jsonEncode(tagIds),
'tag_names': jsonEncode(tagNames),
'sync_status': _syncStatusToString(syncStatus),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
Transaction toTransaction() {
return Transaction(
id: id,
accountId: accountId,
name: name,
date: date,
amount: amount,
currency: currency,
nature: nature,
notes: notes,
categoryId: categoryId,
categoryName: categoryName,
categoryProvided: categoryProvided,
merchantId: merchantId,
merchantName: merchantName,
merchantProvided: merchantProvided,
tagIds: tagIds,
tagNames: tagNames,
tagsProvided: tagsProvided,
);
}
Transaction toTransactionWithSubmittedUpdate({
String? name,
String? notes,
String? categoryId,
String? merchantId,
List<String>? tagIds,
}) {
final nextTagIds = tagIds ?? this.tagIds;
final tagNamesById = <String, String>{};
for (var i = 0; i < this.tagIds.length; i++) {
tagNamesById[this.tagIds[i]] = i < tagNames.length ? tagNames[i] : '';
}
final nextCategoryId = categoryId ?? this.categoryId;
final nextMerchantId = merchantId ?? this.merchantId;
return Transaction(
id: id,
accountId: accountId,
name: name ?? this.name,
date: date,
amount: amount,
currency: currency,
nature: nature,
notes: notes ?? this.notes,
categoryId: nextCategoryId,
categoryName: nextCategoryId == this.categoryId ? categoryName : null,
categoryProvided: true,
merchantId: nextMerchantId,
merchantName: nextMerchantId == this.merchantId ? merchantName : null,
merchantProvided: true,
tagIds: nextTagIds,
tagNames: nextTagIds.map((tagId) => tagNamesById[tagId] ?? '').toList(),
tagsProvided: true,
);
}
OfflineTransaction mergeServerTransaction(
Transaction transaction, {
required String accountId,
}) {
return OfflineTransaction(
id: transaction.id,
localId: localId,
accountId: accountId,
name: transaction.name,
date: transaction.date,
amount: transaction.amount,
currency: transaction.currency,
nature: transaction.nature,
notes: transaction.notes,
categoryId:
transaction.categoryProvided ? transaction.categoryId : categoryId,
categoryName: transaction.categoryProvided
? transaction.categoryName
: categoryName,
merchantId:
transaction.merchantProvided ? transaction.merchantId : merchantId,
merchantName: transaction.merchantProvided
? transaction.merchantName
: merchantName,
tagIds: transaction.tagsProvided ? transaction.tagIds : tagIds,
tagNames: transaction.tagsProvided ? transaction.tagNames : tagNames,
syncStatus: SyncStatus.synced,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
OfflineTransaction copyWith({
String? id,
String? localId,
String? accountId,
String? name,
String? date,
String? amount,
String? currency,
String? nature,
String? notes,
String? categoryId,
String? categoryName,
bool? categoryProvided,
String? merchantId,
String? merchantName,
bool? merchantProvided,
List<String>? tagIds,
List<String>? tagNames,
bool? tagsProvided,
SyncStatus? syncStatus,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return OfflineTransaction(
id: id ?? this.id,
localId: localId ?? this.localId,
accountId: accountId ?? this.accountId,
name: name ?? this.name,
date: date ?? this.date,
amount: amount ?? this.amount,
currency: currency ?? this.currency,
nature: nature ?? this.nature,
notes: notes ?? this.notes,
categoryId: categoryId ?? this.categoryId,
categoryName: categoryName ?? this.categoryName,
categoryProvided: categoryProvided ?? this.categoryProvided,
merchantId: merchantId ?? this.merchantId,
merchantName: merchantName ?? this.merchantName,
merchantProvided: merchantProvided ?? this.merchantProvided,
tagIds: tagIds ?? this.tagIds,
tagNames: tagNames ?? this.tagNames,
tagsProvided: tagsProvided ?? this.tagsProvided,
syncStatus: syncStatus ?? this.syncStatus,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
bool get isSynced => syncStatus == SyncStatus.synced && id != null;
bool get isPending => syncStatus == SyncStatus.pending;
bool get hasFailed => syncStatus == SyncStatus.failed;
static SyncStatus _parseSyncStatus(String status) {
switch (status) {
case 'synced':
return SyncStatus.synced;
case 'pending':
return SyncStatus.pending;
case 'failed':
return SyncStatus.failed;
case 'pending_delete':
return SyncStatus.pendingDelete;
default:
return SyncStatus.pending;
}
}
static List<String> _decodeStringList(String? jsonText) {
if (jsonText == null || jsonText.isEmpty) {
return const [];
}
try {
final decoded = jsonDecode(jsonText);
if (decoded is List) {
return decoded
.where((item) => item != null)
.map((item) => item.toString())
.toList();
}
} catch (_) {
return const [];
}
return const [];
}
static String _syncStatusToString(SyncStatus status) {
switch (status) {
case SyncStatus.synced:
return 'synced';
case SyncStatus.pending:
return 'pending';
case SyncStatus.failed:
return 'failed';
case SyncStatus.pendingDelete:
return 'pending_delete';
}
}
}