Files
sure/mobile/lib/services/offline_storage_service.dart
Lazy Bone fdc2ce1feb Add category support to transactions (#1251)
* 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>
2026-04-13 20:01:08 +02:00

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();
}
}