import 'package:uuid/uuid.dart'; import '../models/offline_transaction.dart'; import '../models/transaction.dart'; import '../models/account.dart'; import 'database_helper.dart'; import 'log_service.dart'; class OfflineStorageService { final DatabaseHelper _dbHelper = DatabaseHelper.instance; final Uuid _uuid = const Uuid(); final LogService _log = LogService.instance; // Transaction operations Future saveTransaction({ required String accountId, required String name, required String date, required String amount, required String currency, required String nature, String? notes, String? categoryId, String? categoryName, String? merchantId, String? merchantName, List tagIds = const [], List tagNames = const [], String? serverId, SyncStatus syncStatus = SyncStatus.pending, }) async { // Generated once when the transaction enters the offline queue, then // persisted as transactions.local_id and reused as the mobile idempotency // key on every replay attempt. final localId = _uuid.v4(); _log.info( 'OfflineStorage', 'saveTransaction called with syncStatus=$syncStatus', ); final transaction = OfflineTransaction( id: serverId, localId: localId, accountId: accountId, name: name, date: date, amount: amount, currency: currency, nature: nature, notes: notes, categoryId: categoryId, categoryName: categoryName, merchantId: merchantId, merchantName: merchantName, tagIds: tagIds, tagNames: tagNames, syncStatus: syncStatus, ); try { await _dbHelper.insertTransaction(transaction.toDatabaseMap()); _log.info('OfflineStorage', 'Transaction saved successfully'); return transaction; } catch (e) { _log.error( 'OfflineStorage', 'Failed to save transaction with ${e.runtimeType}', ); rethrow; } } Future> getTransactions({String? accountId}) async { _log.debug( 'OfflineStorage', 'getTransactions called${accountId != null ? " with account filter" : ""}', ); final transactionMaps = await _dbHelper.getTransactions(accountId: accountId); _log.debug('OfflineStorage', 'Retrieved ${transactionMaps.length} transaction maps from database'); final transactions = transactionMaps .map((map) => OfflineTransaction.fromDatabaseMap(map)) .toList(); _log.debug( 'OfflineStorage', 'Returning ${transactions.length} transactions'); return transactions; } Future getTransactionByLocalId(String localId) async { final map = await _dbHelper.getTransactionByLocalId(localId); return map != null ? OfflineTransaction.fromDatabaseMap(map) : null; } Future getTransactionByServerId(String serverId) async { final map = await _dbHelper.getTransactionByServerId(serverId); return map != null ? OfflineTransaction.fromDatabaseMap(map) : null; } Future> getPendingTransactions() async { final transactionMaps = await _dbHelper.getPendingTransactions(); return transactionMaps .map((map) => OfflineTransaction.fromDatabaseMap(map)) .toList(); } Future> getPendingDeletes() async { final transactionMaps = await _dbHelper.getPendingDeletes(); return transactionMaps .map((map) => OfflineTransaction.fromDatabaseMap(map)) .toList(); } Future updateTransactionSyncStatus({ required String localId, required SyncStatus syncStatus, String? serverId, }) async { final existing = await getTransactionByLocalId(localId); if (existing == null) return; final updated = existing.copyWith( syncStatus: syncStatus, id: serverId ?? existing.id, updatedAt: DateTime.now(), ); await _dbHelper.updateTransaction(localId, updated.toDatabaseMap()); } Future deleteTransaction(String localId) async { await _dbHelper.deleteTransaction(localId); } Future deleteTransactionByServerId(String serverId) async { await _dbHelper.deleteTransactionByServerId(serverId); } /// Mark a transaction for pending deletion (offline delete) Future markTransactionForDeletion(String serverId) async { _log.info('OfflineStorage', 'Marking transaction for pending deletion'); // Find the transaction by server ID final existing = await getTransactionByServerId(serverId); if (existing == null) { _log.warning( 'OfflineStorage', 'Transaction not found, cannot mark for deletion', ); return; } // Update its sync status to pendingDelete final updated = existing.copyWith( syncStatus: SyncStatus.pendingDelete, updatedAt: DateTime.now(), ); await _dbHelper.updateTransaction( existing.localId, updated.toDatabaseMap()); _log.info('OfflineStorage', 'Transaction marked as pending_delete'); } /// Undo a pending transaction operation (either pending create or pending delete) Future undoPendingTransaction( String localId, SyncStatus currentStatus) async { _log.info( 'OfflineStorage', 'Undoing pending transaction with status $currentStatus', ); final existing = await getTransactionByLocalId(localId); if (existing == null) { _log.warning('OfflineStorage', 'Transaction not found, cannot undo'); return false; } if (currentStatus == SyncStatus.pending) { // For pending creates: delete the transaction completely _log.info('OfflineStorage', 'Deleting pending create transaction'); await deleteTransaction(localId); return true; } else if (currentStatus == SyncStatus.pendingDelete) { // For pending deletes: restore to synced status _log.info( 'OfflineStorage', 'Restoring pending delete transaction to synced', ); final updated = existing.copyWith( syncStatus: SyncStatus.synced, updatedAt: DateTime.now(), ); await _dbHelper.updateTransaction(localId, updated.toDatabaseMap()); return true; } _log.warning( 'OfflineStorage', 'Cannot undo transaction with status $currentStatus'); return false; } Future syncTransactionsFromServer( List serverTransactions) async { _log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); // Use upsert logic instead of clear + insert to preserve recently uploaded transactions _log.info('OfflineStorage', 'Upserting all transactions from server (preserving pending/failed)'); int upsertedCount = 0; int emptyAccountIdCount = 0; for (final transaction in serverTransactions) { if (transaction.id != null) { if (transaction.accountId.isEmpty) { emptyAccountIdCount++; } await upsertTransactionFromServer(transaction); upsertedCount++; } } _log.info( 'OfflineStorage', 'Upserted $upsertedCount transactions from server'); if (emptyAccountIdCount > 0) { _log.error('OfflineStorage', 'WARNING: $emptyAccountIdCount transactions had EMPTY accountId!'); } } Future upsertTransactionFromServer( Transaction transaction, { String? accountId, }) async { if (transaction.id == null) { _log.warning('OfflineStorage', 'Skipping transaction with null ID'); return; } // If accountId is provided and transaction.accountId is empty, use the provided one final effectiveAccountId = transaction.accountId.isEmpty && accountId != null ? accountId : transaction.accountId; // Log if transaction has empty accountId if (transaction.accountId.isEmpty) { final fallbackApplied = accountId != null && accountId.isNotEmpty; _log.warning( 'OfflineStorage', 'Transaction has empty accountId from server; fallbackApplied=$fallbackApplied', ); } // Check if we already have this transaction final existing = await getTransactionByServerId(transaction.id!); if (existing != null) { // Update existing transaction, preserving its accountId if effectiveAccountId is empty final finalAccountId = effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId; if (finalAccountId.isEmpty) { _log.error( 'OfflineStorage', 'CRITICAL: Updating transaction with EMPTY accountId; operation=update fallbackApplied=false existingAccountIdPresent=false', ); } final updated = existing.mergeServerTransaction( transaction, accountId: finalAccountId, ); await _dbHelper.updateTransaction( existing.localId, updated.toDatabaseMap()); } else { // Insert new transaction if (effectiveAccountId.isEmpty) { _log.error( 'OfflineStorage', 'CRITICAL: Inserting transaction with EMPTY accountId; operation=insert fallbackApplied=false', ); } final offlineTransaction = OfflineTransaction( id: transaction.id, localId: _uuid.v4(), accountId: effectiveAccountId, name: transaction.name, date: transaction.date, amount: transaction.amount, currency: transaction.currency, nature: transaction.nature, notes: transaction.notes, categoryId: transaction.categoryId, categoryName: transaction.categoryName, merchantId: transaction.merchantId, merchantName: transaction.merchantName, tagIds: transaction.tagIds, tagNames: transaction.tagNames, syncStatus: SyncStatus.synced, ); await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap()); } } Future clearTransactions() async { await _dbHelper.clearTransactions(); } // Account operations (for caching) Future saveAccount(Account account) async { final accountMap = { 'id': account.id, 'name': account.name, 'balance': account.balance, 'currency': account.currency, 'classification': account.classification, 'account_type': account.accountType, 'synced_at': DateTime.now().toIso8601String(), }; await _dbHelper.insertAccount(accountMap); } Future saveAccounts(List accounts) async { final accountMaps = accounts .map((account) => { 'id': account.id, 'name': account.name, 'balance': account.balance, 'currency': account.currency, 'classification': account.classification, 'account_type': account.accountType, 'synced_at': DateTime.now().toIso8601String(), }) .toList(); await _dbHelper.insertAccounts(accountMaps); } Future> getAccounts() async { final accountMaps = await _dbHelper.getAccounts(); return accountMaps.map((map) => Account.fromJson(map)).toList(); } Future getAccountById(String id) async { final map = await _dbHelper.getAccountById(id); return map != null ? Account.fromJson(map) : null; } Future clearAccounts() async { await _dbHelper.clearAccounts(); } // Utility methods Future clearAllData() async { await _dbHelper.clearAllData(); } }