mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 14:54:49 +00:00
* Fix mobile app to fetch all transactions with pagination
The mobile app was only fetching 25 transactions per account because:
1. TransactionsService didn't pass pagination parameters to the API
2. The backend defaults to 25 records per page when no per_page is specified
3. SyncService didn't implement pagination to fetch all pages
Changes:
- Updated TransactionsService.getTransactions() to accept page and perPage parameters
- Modified the method to extract and return pagination metadata from API response
- Updated SyncService.syncFromServer() to fetch all pages (up to 100 per page)
- Added pagination loop to continue fetching until all pages are retrieved
- Enhanced logging to show pagination progress
This ensures users see all their transactions in the mobile app, not just the first 25.
* Add clear local data feature and enhanced sync logging
Added features:
1. Clear Local Data button in Settings
- Allows users to clear all cached transactions and accounts
- Shows confirmation dialog before clearing
- Displays success/error feedback
2. Enhanced sync logging for debugging
- Added detailed logs in syncFromServer to track pagination
- Shows page-by-page progress with transaction counts
- Logs pagination metadata (total pages, total count, etc.)
- Tracks upsert progress every 50 transactions
- Added clear section markers for easier log reading
3. Simplified upsertTransactionFromServer logging
- Removed verbose debug logs to reduce noise
- Keeps only essential error/warning logs
This will help users troubleshoot sync issues by:
- Clearing stale data and forcing a fresh sync
- Providing detailed logs to identify where sync might fail
* Fix transaction accountId parsing from API response
The mobile app was only showing 25 transactions per account because:
- The backend API returns account info in nested format: {"account": {"id": "xxx"}}
- The mobile Transaction model expected flat format: {"account_id": "xxx"}
- When parsing, accountId was always empty, so database queries by account_id returned incomplete results
Changes:
1. Updated Transaction.fromJson to handle both formats:
- New format: {"account": {"id": "xxx", "name": "..."}}
- Old format: {"account_id": "xxx"} (for backward compatibility)
2. Fixed classification/nature field parsing:
- Backend sends "classification" field (income/expense)
- Mobile uses "nature" field
- Now handles both fields correctly
3. Added debug logging to identify empty accountId issues:
- Logs first transaction's accountId when syncing
- Counts and warns about transactions with empty accountId
- Shows critical errors when trying to save with empty accountId
This ensures all transactions from the server are correctly associated with their accounts in the local database.
---------
Co-authored-by: Claude <noreply@anthropic.com>
318 lines
11 KiB
Dart
318 lines
11 KiB
Dart
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<OfflineTransaction> saveTransaction({
|
|
required String accountId,
|
|
required String name,
|
|
required String date,
|
|
required String amount,
|
|
required String currency,
|
|
required String nature,
|
|
String? notes,
|
|
String? serverId,
|
|
SyncStatus syncStatus = SyncStatus.pending,
|
|
}) async {
|
|
_log.info('OfflineStorage', 'saveTransaction called: name=$name, amount=$amount, accountId=$accountId, syncStatus=$syncStatus');
|
|
|
|
final localId = _uuid.v4();
|
|
final transaction = OfflineTransaction(
|
|
id: serverId,
|
|
localId: localId,
|
|
accountId: accountId,
|
|
name: name,
|
|
date: date,
|
|
amount: amount,
|
|
currency: currency,
|
|
nature: nature,
|
|
notes: notes,
|
|
syncStatus: syncStatus,
|
|
);
|
|
|
|
try {
|
|
await _dbHelper.insertTransaction(transaction.toDatabaseMap());
|
|
_log.info('OfflineStorage', 'Transaction saved successfully with localId: $localId');
|
|
return transaction;
|
|
} catch (e) {
|
|
_log.error('OfflineStorage', 'Failed to save transaction: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<List<OfflineTransaction>> getTransactions({String? accountId}) async {
|
|
_log.debug('OfflineStorage', 'getTransactions called with accountId: $accountId');
|
|
final transactionMaps = await _dbHelper.getTransactions(accountId: accountId);
|
|
_log.debug('OfflineStorage', 'Retrieved ${transactionMaps.length} transaction maps from database');
|
|
|
|
if (transactionMaps.isNotEmpty && accountId != null) {
|
|
_log.debug('OfflineStorage', 'Sample transaction account_ids:');
|
|
for (int i = 0; i < transactionMaps.take(3).length; i++) {
|
|
final map = transactionMaps[i];
|
|
_log.debug('OfflineStorage', ' - Transaction ${map['server_id']}: account_id="${map['account_id']}"');
|
|
}
|
|
}
|
|
|
|
final transactions = transactionMaps
|
|
.map((map) => OfflineTransaction.fromDatabaseMap(map))
|
|
.toList();
|
|
_log.debug('OfflineStorage', 'Returning ${transactions.length} transactions');
|
|
return transactions;
|
|
}
|
|
|
|
Future<OfflineTransaction?> getTransactionByLocalId(String localId) async {
|
|
final map = await _dbHelper.getTransactionByLocalId(localId);
|
|
return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
|
|
}
|
|
|
|
Future<OfflineTransaction?> getTransactionByServerId(String serverId) async {
|
|
final map = await _dbHelper.getTransactionByServerId(serverId);
|
|
return map != null ? OfflineTransaction.fromDatabaseMap(map) : null;
|
|
}
|
|
|
|
Future<List<OfflineTransaction>> getPendingTransactions() async {
|
|
final transactionMaps = await _dbHelper.getPendingTransactions();
|
|
return transactionMaps
|
|
.map((map) => OfflineTransaction.fromDatabaseMap(map))
|
|
.toList();
|
|
}
|
|
|
|
Future<List<OfflineTransaction>> getPendingDeletes() async {
|
|
final transactionMaps = await _dbHelper.getPendingDeletes();
|
|
return transactionMaps
|
|
.map((map) => OfflineTransaction.fromDatabaseMap(map))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> 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<void> deleteTransaction(String localId) async {
|
|
await _dbHelper.deleteTransaction(localId);
|
|
}
|
|
|
|
Future<void> deleteTransactionByServerId(String serverId) async {
|
|
await _dbHelper.deleteTransactionByServerId(serverId);
|
|
}
|
|
|
|
/// Mark a transaction for pending deletion (offline delete)
|
|
Future<void> markTransactionForDeletion(String serverId) async {
|
|
_log.info('OfflineStorage', 'Marking transaction $serverId for pending deletion');
|
|
|
|
// Find the transaction by server ID
|
|
final existing = await getTransactionByServerId(serverId);
|
|
if (existing == null) {
|
|
_log.warning('OfflineStorage', 'Transaction $serverId 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 ${existing.localId} marked as pending_delete');
|
|
}
|
|
|
|
/// Undo a pending transaction operation (either pending create or pending delete)
|
|
Future<bool> undoPendingTransaction(String localId, SyncStatus currentStatus) async {
|
|
_log.info('OfflineStorage', 'Undoing pending transaction $localId with status $currentStatus');
|
|
|
|
final existing = await getTransactionByLocalId(localId);
|
|
if (existing == null) {
|
|
_log.warning('OfflineStorage', 'Transaction $localId not found, cannot undo');
|
|
return false;
|
|
}
|
|
|
|
if (currentStatus == SyncStatus.pending) {
|
|
// For pending creates: delete the transaction completely
|
|
_log.info('OfflineStorage', 'Deleting pending create transaction $localId');
|
|
await deleteTransaction(localId);
|
|
return true;
|
|
} else if (currentStatus == SyncStatus.pendingDelete) {
|
|
// For pending deletes: restore to synced status
|
|
_log.info('OfflineStorage', 'Restoring pending delete transaction $localId 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<void> syncTransactionsFromServer(List<Transaction> serverTransactions) async {
|
|
_log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server');
|
|
|
|
// Log first transaction's accountId for debugging
|
|
if (serverTransactions.isNotEmpty) {
|
|
final firstTx = serverTransactions.first;
|
|
_log.info('OfflineStorage', 'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"');
|
|
}
|
|
|
|
// 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<void> 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) {
|
|
_log.warning('OfflineStorage', 'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId');
|
|
}
|
|
|
|
// 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 ${transaction.id} with EMPTY accountId!');
|
|
}
|
|
|
|
final updated = OfflineTransaction(
|
|
id: transaction.id,
|
|
localId: existing.localId,
|
|
accountId: finalAccountId,
|
|
name: transaction.name,
|
|
date: transaction.date,
|
|
amount: transaction.amount,
|
|
currency: transaction.currency,
|
|
nature: transaction.nature,
|
|
notes: transaction.notes,
|
|
syncStatus: SyncStatus.synced,
|
|
);
|
|
await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap());
|
|
} else {
|
|
// Insert new transaction
|
|
if (effectiveAccountId.isEmpty) {
|
|
_log.error('OfflineStorage', 'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!');
|
|
}
|
|
|
|
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,
|
|
syncStatus: SyncStatus.synced,
|
|
);
|
|
await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap());
|
|
}
|
|
}
|
|
|
|
Future<void> clearTransactions() async {
|
|
await _dbHelper.clearTransactions();
|
|
}
|
|
|
|
// Account operations (for caching)
|
|
Future<void> 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<void> saveAccounts(List<Account> 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<List<Account>> getAccounts() async {
|
|
final accountMaps = await _dbHelper.getAccounts();
|
|
return accountMaps.map((map) => Account.fromJson(map)).toList();
|
|
}
|
|
|
|
Future<Account?> getAccountById(String id) async {
|
|
final map = await _dbHelper.getAccountById(id);
|
|
return map != null ? Account.fromJson(map) : null;
|
|
}
|
|
|
|
Future<void> clearAccounts() async {
|
|
await _dbHelper.clearAccounts();
|
|
}
|
|
|
|
// Utility methods
|
|
Future<void> clearAllData() async {
|
|
await _dbHelper.clearAllData();
|
|
}
|
|
}
|