mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
* Move debug logs button from Home to Settings page, remove refresh/logout from Home AppBar - Remove Debug Logs, Refresh, and Sign Out buttons from DashboardScreen AppBar - Add Debug Logs ListTile entry in SettingsScreen under app info section - Remove unused _handleLogout method from DashboardScreen - Remove unused log_viewer_screen.dart import from DashboardScreen https://claude.ai/code/session_017XQZdaEwUuRS75tJMcHzB9 * Add category picker to Android transaction form Implements category selection when creating transactions in the mobile app. Uses the existing /api/v1/categories endpoint to fetch categories and sends category_id when creating transactions via the API. New files: - Category model, CategoriesService, CategoriesProvider Updated: - Transaction/OfflineTransaction models with categoryId/categoryName - TransactionsService/Provider to pass category_id - DB schema v2 migration for category columns - TransactionFormScreen with category dropdown in "More" section Closes #78 https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix ambiguous Category import in CategoriesProvider Hide Flutter's built-in Category annotation from foundation.dart to resolve name collision with our Category model. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Add category filter on Dashboard, clear categories on data reset, fix ambiguous imports - Add CategoryFilter widget (horizontal chip row like CurrencyFilter) - Show category filter on Dashboard below currency filter (2nd row) - Add "Show Category Filter" toggle in Settings > Display section - Clear CategoriesProvider on "Clear Local Data" and "Reset Account" - Fix Category name collision: hide Flutter's Category from material.dart - Add getShowCategoryFilter/setShowCategoryFilter to PreferencesService https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix Category name collision using prefixed imports Use 'import as models' instead of 'hide Category' to avoid undefined_hidden_name warnings with flutter/material.dart. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix duplicate column error in SQLite migration Check if category_id/category_name columns exist before running ALTER TABLE, preventing crashes when the DB was already at v2 or the migration had partially succeeded. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Move CategoryFilter from dashboard to transaction list screen CategoryFilter was filtering accounts on the dashboard but accounts are already grouped by type. Moved it to TransactionsListScreen where it filters transactions by category, which is the correct placement. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Add category tag badge next to transaction name Shows an oval-bordered category label after each transaction's name for quick visual identification of transaction types. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Address review findings for category feature 1. Category.fromJson now recursively parses parent chain; displayName walks all ancestors (e.g. "Grandparent > Parent > Child") 2. CategoriesProvider.fetchCategories guards against concurrent/duplicate calls by checking _isLoading and _hasFetched early 3. CategoryFilter chips use displayName to distinguish subcategories 4. Transaction badge resolves full displayName from CategoriesProvider with overflow ellipsis for long paths 5. Offline storage preserves local category values when server response omits them (coalesce with ??) https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix missing closing brace in PreferencesService causing theme_provider analyze errors https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix sync category upload, empty-state refresh, badge reactivity, and preferences syntax - Add categoryId to SyncService pending transaction upload payload - Replace non-scrollable Center with ListView for empty filter state so RefreshIndicator works when no transactions match - Use listen:true for CategoriesProvider in badge display so badges rebuild when categories finish loading - Fix missing closing brace in PreferencesService.setShowCategoryFilter https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
326 lines
11 KiB
Dart
326 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? categoryId,
|
|
String? categoryName,
|
|
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,
|
|
categoryId: categoryId,
|
|
categoryName: categoryName,
|
|
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,
|
|
categoryId: transaction.categoryId ?? existing.categoryId,
|
|
categoryName: transaction.categoryName ?? existing.categoryName,
|
|
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,
|
|
categoryId: transaction.categoryId,
|
|
categoryName: transaction.categoryName,
|
|
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();
|
|
}
|
|
}
|