Files
sure/mobile/lib/services/database_helper.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

499 lines
14 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'log_service.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
static final Map<String, Map<String, dynamic>> _memoryTransactions = {};
static final Map<String, Map<String, dynamic>> _memoryAccounts = {};
static bool _webStorageLogged = false;
final LogService _log = LogService.instance;
DatabaseHelper._init();
bool get _useInMemoryStore => kIsWeb;
void _ensureWebStoreReady() {
if (!_useInMemoryStore || _webStorageLogged) return;
_webStorageLogged = true;
_log.info(
'DatabaseHelper',
'Using in-memory storage on web (sqflite is not supported in browser builds).',
);
}
int _compareDesc(String? left, String? right) {
return (right ?? '').compareTo(left ?? '');
}
int _compareAsc(String? left, String? right) {
return (left ?? '').compareTo(right ?? '');
}
Future<Database> get database async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
throw StateError('sqflite database is not available on web.');
}
if (_database != null) return _database!;
try {
_database = await _initDB('sure_offline.db');
return _database!;
} catch (e, stackTrace) {
_log.error('DatabaseHelper', 'Error initializing local database sure_offline.db: $e');
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stackTrace,
library: 'database_helper',
context: ErrorDescription('while opening sure_offline.db'),
),
);
rethrow;
}
}
Future<Database> _initDB(String filePath) async {
try {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
return await openDatabase(
path,
version: 2,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
} catch (e, stackTrace) {
_log.error('DatabaseHelper', 'Error opening database file "$filePath": $e');
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stackTrace,
library: 'database_helper',
context: ErrorDescription('while initializing the sqflite database'),
),
);
rethrow;
}
}
Future<void> _createDB(Database db, int version) async {
try {
// Transactions table
await db.execute('''
CREATE TABLE transactions (
local_id TEXT PRIMARY KEY,
server_id TEXT,
account_id TEXT NOT NULL,
name TEXT NOT NULL,
date TEXT NOT NULL,
amount TEXT NOT NULL,
currency TEXT NOT NULL,
nature TEXT NOT NULL,
notes TEXT,
category_id TEXT,
category_name TEXT,
sync_status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// Accounts table (cached from server)
await db.execute('''
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
balance TEXT NOT NULL,
currency TEXT NOT NULL,
classification TEXT,
account_type TEXT NOT NULL,
synced_at TEXT NOT NULL
)
''');
// Create indexes for better query performance
await db.execute('''
CREATE INDEX idx_transactions_sync_status
ON transactions(sync_status)
''');
await db.execute('''
CREATE INDEX idx_transactions_account_id
ON transactions(account_id)
''');
await db.execute('''
CREATE INDEX idx_transactions_date
ON transactions(date DESC)
''');
// Index on server_id for faster lookups by server ID
await db.execute('''
CREATE INDEX idx_transactions_server_id
ON transactions(server_id)
''');
} catch (e, stackTrace) {
_log.error('DatabaseHelper', 'Error creating local database schema: $e');
FlutterError.reportError(
FlutterErrorDetails(
exception: e,
stack: stackTrace,
library: 'database_helper',
context: ErrorDescription('while creating tables and indexes'),
),
);
rethrow;
}
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
final columns = await db.rawQuery('PRAGMA table_info(transactions)');
final columnNames = columns.map((c) => c['name'] as String).toSet();
if (!columnNames.contains('category_id')) {
await db.execute('ALTER TABLE transactions ADD COLUMN category_id TEXT');
}
if (!columnNames.contains('category_name')) {
await db.execute('ALTER TABLE transactions ADD COLUMN category_name TEXT');
}
}
}
// Transaction CRUD operations
Future<String> insertTransaction(Map<String, dynamic> transaction) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final localId = transaction['local_id'] as String;
_memoryTransactions[localId] = Map<String, dynamic>.from(transaction);
return localId;
}
final db = await database;
_log.debug('DatabaseHelper', 'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}');
await db.insert(
'transactions',
transaction,
conflictAlgorithm: ConflictAlgorithm.replace,
);
_log.debug('DatabaseHelper', 'Transaction inserted successfully');
return transaction['local_id'] as String;
}
Future<List<Map<String, dynamic>>> getTransactions({String? accountId}) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final results = _memoryTransactions.values
.where((transaction) {
final storedAccountId = transaction['account_id'] as String?;
return accountId == null || storedAccountId == accountId;
})
.map((transaction) => Map<String, dynamic>.from(transaction))
.toList();
results.sort((a, b) {
final dateCompare = _compareDesc(a['date'] as String?, b['date'] as String?);
if (dateCompare != 0) return dateCompare;
return _compareDesc(a['created_at'] as String?, b['created_at'] as String?);
});
return results;
}
final db = await database;
if (accountId != null) {
_log.debug('DatabaseHelper', 'Querying transactions WHERE account_id = "$accountId"');
final results = await db.query(
'transactions',
where: 'account_id = ?',
whereArgs: [accountId],
orderBy: 'date DESC, created_at DESC',
);
_log.debug('DatabaseHelper', 'Query returned ${results.length} results');
return results;
} else {
_log.debug('DatabaseHelper', 'Querying ALL transactions');
final results = await db.query(
'transactions',
orderBy: 'date DESC, created_at DESC',
);
_log.debug('DatabaseHelper', 'Query returned ${results.length} results');
return results;
}
}
Future<Map<String, dynamic>?> getTransactionByLocalId(String localId) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final transaction = _memoryTransactions[localId];
return transaction != null ? Map<String, dynamic>.from(transaction) : null;
}
final db = await database;
final results = await db.query(
'transactions',
where: 'local_id = ?',
whereArgs: [localId],
limit: 1,
);
return results.isNotEmpty ? results.first : null;
}
Future<Map<String, dynamic>?> getTransactionByServerId(String serverId) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
for (final transaction in _memoryTransactions.values) {
if (transaction['server_id'] == serverId) {
return Map<String, dynamic>.from(transaction);
}
}
return null;
}
final db = await database;
final results = await db.query(
'transactions',
where: 'server_id = ?',
whereArgs: [serverId],
limit: 1,
);
return results.isNotEmpty ? results.first : null;
}
Future<List<Map<String, dynamic>>> getPendingTransactions() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final results = _memoryTransactions.values
.where((transaction) => transaction['sync_status'] == 'pending')
.map((transaction) => Map<String, dynamic>.from(transaction))
.toList();
results.sort(
(a, b) => _compareAsc(a['created_at'] as String?, b['created_at'] as String?),
);
return results;
}
final db = await database;
return await db.query(
'transactions',
where: 'sync_status = ?',
whereArgs: ['pending'],
orderBy: 'created_at ASC',
);
}
Future<List<Map<String, dynamic>>> getPendingDeletes() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final results = _memoryTransactions.values
.where((transaction) => transaction['sync_status'] == 'pending_delete')
.map((transaction) => Map<String, dynamic>.from(transaction))
.toList();
results.sort(
(a, b) => _compareAsc(a['updated_at'] as String?, b['updated_at'] as String?),
);
return results;
}
final db = await database;
return await db.query(
'transactions',
where: 'sync_status = ?',
whereArgs: ['pending_delete'],
orderBy: 'updated_at ASC',
);
}
Future<int> updateTransaction(String localId, Map<String, dynamic> transaction) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
if (!_memoryTransactions.containsKey(localId)) {
return 0;
}
final updated = Map<String, dynamic>.from(transaction);
updated['local_id'] = localId;
_memoryTransactions[localId] = updated;
return 1;
}
final db = await database;
return await db.update(
'transactions',
transaction,
where: 'local_id = ?',
whereArgs: [localId],
);
}
Future<int> deleteTransaction(String localId) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
return _memoryTransactions.remove(localId) != null ? 1 : 0;
}
final db = await database;
return await db.delete(
'transactions',
where: 'local_id = ?',
whereArgs: [localId],
);
}
Future<int> deleteTransactionByServerId(String serverId) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
String? localIdToRemove;
for (final entry in _memoryTransactions.entries) {
if (entry.value['server_id'] == serverId) {
localIdToRemove = entry.key;
break;
}
}
if (localIdToRemove == null) return 0;
_memoryTransactions.remove(localIdToRemove);
return 1;
}
final db = await database;
return await db.delete(
'transactions',
where: 'server_id = ?',
whereArgs: [serverId],
);
}
Future<void> clearTransactions() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
_memoryTransactions.clear();
return;
}
final db = await database;
await db.delete('transactions');
}
Future<void> clearSyncedTransactions() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final keysToRemove = _memoryTransactions.entries
.where((entry) => entry.value['sync_status'] == 'synced')
.map((entry) => entry.key)
.toList();
for (final key in keysToRemove) {
_memoryTransactions.remove(key);
}
return;
}
final db = await database;
_log.debug('DatabaseHelper', 'Clearing only synced transactions, keeping pending/failed');
await db.delete(
'transactions',
where: 'sync_status = ?',
whereArgs: ['synced'],
);
}
// Account CRUD operations (for caching)
Future<void> insertAccount(Map<String, dynamic> account) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final id = account['id'] as String;
_memoryAccounts[id] = Map<String, dynamic>.from(account);
return;
}
final db = await database;
await db.insert(
'accounts',
account,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> insertAccounts(List<Map<String, dynamic>> accounts) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
for (final account in accounts) {
final id = account['id'] as String;
_memoryAccounts[id] = Map<String, dynamic>.from(account);
}
return;
}
final db = await database;
final batch = db.batch();
for (final account in accounts) {
batch.insert(
'accounts',
account,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}
Future<List<Map<String, dynamic>>> getAccounts() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final results = _memoryAccounts.values
.map((account) => Map<String, dynamic>.from(account))
.toList();
results.sort(
(a, b) => _compareAsc(a['name'] as String?, b['name'] as String?),
);
return results;
}
final db = await database;
return await db.query('accounts', orderBy: 'name ASC');
}
Future<Map<String, dynamic>?> getAccountById(String id) async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
final account = _memoryAccounts[id];
return account != null ? Map<String, dynamic>.from(account) : null;
}
final db = await database;
final results = await db.query(
'accounts',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return results.isNotEmpty ? results.first : null;
}
Future<void> clearAccounts() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
_memoryAccounts.clear();
return;
}
final db = await database;
await db.delete('accounts');
}
// Utility methods
Future<void> clearAllData() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
_memoryTransactions.clear();
_memoryAccounts.clear();
return;
}
final db = await database;
await db.delete('transactions');
await db.delete('accounts');
}
Future<void> close() async {
if (_useInMemoryStore) {
_ensureWebStoreReady();
_memoryTransactions.clear();
_memoryAccounts.clear();
return;
}
if (_database != null) {
await _database!.close();
_database = null;
}
}
}