mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 05:35:00 +00: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>
417 lines
14 KiB
Dart
417 lines
14 KiB
Dart
import 'dart:collection';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../models/transaction.dart';
|
|
import '../models/offline_transaction.dart';
|
|
import '../services/transactions_service.dart';
|
|
import '../services/offline_storage_service.dart';
|
|
import '../services/sync_service.dart';
|
|
import '../services/connectivity_service.dart';
|
|
import '../services/log_service.dart';
|
|
|
|
class TransactionsProvider with ChangeNotifier {
|
|
final TransactionsService _transactionsService = TransactionsService();
|
|
final OfflineStorageService _offlineStorage = OfflineStorageService();
|
|
final SyncService _syncService = SyncService();
|
|
final LogService _log = LogService.instance;
|
|
|
|
List<OfflineTransaction> _transactions = [];
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
ConnectivityService? _connectivityService;
|
|
String? _lastAccessToken;
|
|
String? _currentAccountId; // Track current account for filtering
|
|
bool _isAutoSyncing = false;
|
|
bool _isListenerAttached = false;
|
|
bool _isDisposed = false;
|
|
|
|
List<Transaction> get transactions =>
|
|
UnmodifiableListView(_transactions.map((t) => t.toTransaction()));
|
|
|
|
List<OfflineTransaction> get offlineTransactions =>
|
|
UnmodifiableListView(_transactions);
|
|
|
|
bool get isLoading => _isLoading;
|
|
String? get error => _error;
|
|
bool get hasPendingTransactions =>
|
|
_transactions.any((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete);
|
|
int get pendingCount =>
|
|
_transactions.where((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete).length;
|
|
|
|
SyncService get syncService => _syncService;
|
|
|
|
void setConnectivityService(ConnectivityService service) {
|
|
_connectivityService = service;
|
|
if (!_isListenerAttached) {
|
|
_connectivityService?.addListener(_onConnectivityChanged);
|
|
_isListenerAttached = true;
|
|
}
|
|
}
|
|
|
|
void _onConnectivityChanged() {
|
|
if (_isDisposed) return;
|
|
|
|
// Auto-sync when connectivity is restored
|
|
if (_connectivityService?.isOnline == true &&
|
|
hasPendingTransactions &&
|
|
_lastAccessToken != null &&
|
|
!_isAutoSyncing) {
|
|
_log.info('TransactionsProvider', 'Connectivity restored, auto-syncing $pendingCount pending transactions');
|
|
_isAutoSyncing = true;
|
|
|
|
// Fire and forget - we don't await to avoid blocking connectivity listener
|
|
// Use callbacks to handle completion and errors asynchronously
|
|
syncTransactions(accessToken: _lastAccessToken!)
|
|
.then((_) {
|
|
if (!_isDisposed) {
|
|
_log.info('TransactionsProvider', 'Auto-sync completed successfully');
|
|
}
|
|
})
|
|
.catchError((e) {
|
|
if (!_isDisposed) {
|
|
_log.error('TransactionsProvider', 'Auto-sync failed: $e');
|
|
}
|
|
})
|
|
.whenComplete(() {
|
|
if (!_isDisposed) {
|
|
_isAutoSyncing = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Helper to check if object is still valid
|
|
bool get mounted => !_isDisposed;
|
|
|
|
/// Fetch transactions (offline-first approach)
|
|
Future<void> fetchTransactions({
|
|
required String accessToken,
|
|
String? accountId,
|
|
bool forceSync = false,
|
|
}) async {
|
|
_lastAccessToken = accessToken; // Store for auto-sync
|
|
_currentAccountId = accountId; // Track current account
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Always load from local storage first
|
|
final localTransactions = await _offlineStorage.getTransactions(
|
|
accountId: accountId,
|
|
);
|
|
|
|
_log.debug('TransactionsProvider', 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)');
|
|
|
|
_transactions = localTransactions;
|
|
notifyListeners();
|
|
|
|
// If online and force sync, or if local storage is empty, sync from server
|
|
final isOnline = _connectivityService?.isOnline ?? true;
|
|
_log.debug('TransactionsProvider', 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}');
|
|
|
|
if (isOnline && (forceSync || localTransactions.isEmpty)) {
|
|
_log.debug('TransactionsProvider', 'Syncing from server for accountId: $accountId');
|
|
final result = await _syncService.syncFromServer(
|
|
accessToken: accessToken,
|
|
accountId: accountId,
|
|
);
|
|
|
|
if (result.success) {
|
|
_log.info('TransactionsProvider', 'Sync successful, synced ${result.syncedCount} transactions');
|
|
// Reload from local storage after sync
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: accountId,
|
|
);
|
|
_log.debug('TransactionsProvider', 'After sync, loaded ${updatedTransactions.length} transactions from local storage');
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
} else {
|
|
_log.error('TransactionsProvider', 'Sync failed: ${result.error}');
|
|
_error = result.error;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Error in fetchTransactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Create a new transaction (offline-first)
|
|
Future<bool> createTransaction({
|
|
required String accessToken,
|
|
required String accountId,
|
|
required String name,
|
|
required String date,
|
|
required String amount,
|
|
required String currency,
|
|
required String nature,
|
|
String? notes,
|
|
String? categoryId,
|
|
String? categoryName,
|
|
}) async {
|
|
_lastAccessToken = accessToken; // Store for auto-sync
|
|
|
|
try {
|
|
final isOnline = _connectivityService?.isOnline ?? false;
|
|
|
|
_log.info('TransactionsProvider', 'Creating transaction: $name, amount: $amount, online: $isOnline');
|
|
|
|
// ALWAYS save locally first (offline-first strategy)
|
|
final localTransaction = await _offlineStorage.saveTransaction(
|
|
accountId: accountId,
|
|
name: name,
|
|
date: date,
|
|
amount: amount,
|
|
currency: currency,
|
|
nature: nature,
|
|
notes: notes,
|
|
categoryId: categoryId,
|
|
categoryName: categoryName,
|
|
syncStatus: SyncStatus.pending, // Start as pending
|
|
);
|
|
|
|
_log.info('TransactionsProvider', 'Transaction saved locally with ID: ${localTransaction.localId}');
|
|
|
|
// Reload transactions to show the new one immediately
|
|
await fetchTransactions(accessToken: accessToken, accountId: accountId);
|
|
|
|
// If online, try to upload in background
|
|
if (isOnline) {
|
|
_log.info('TransactionsProvider', 'Attempting to upload transaction to server...');
|
|
|
|
// Don't await - upload in background
|
|
_transactionsService.createTransaction(
|
|
accessToken: accessToken,
|
|
accountId: accountId,
|
|
name: name,
|
|
date: date,
|
|
amount: amount,
|
|
currency: currency,
|
|
nature: nature,
|
|
notes: notes,
|
|
categoryId: categoryId,
|
|
).then((result) async {
|
|
if (_isDisposed) return;
|
|
|
|
if (result['success'] == true) {
|
|
_log.info('TransactionsProvider', 'Transaction uploaded successfully');
|
|
final serverTransaction = result['transaction'] as Transaction;
|
|
// Update local transaction with server ID and mark as synced
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: localTransaction.localId,
|
|
syncStatus: SyncStatus.synced,
|
|
serverId: serverTransaction.id,
|
|
);
|
|
// Reload to update UI
|
|
await fetchTransactions(accessToken: accessToken, accountId: accountId);
|
|
} else {
|
|
_log.warning('TransactionsProvider', 'Server upload failed: ${result['error']}. Transaction will sync later.');
|
|
}
|
|
}).catchError((e) {
|
|
if (_isDisposed) return;
|
|
|
|
_log.error('TransactionsProvider', 'Exception during upload: $e');
|
|
_error = 'Failed to upload transaction. It will sync when online.';
|
|
notifyListeners();
|
|
});
|
|
} else {
|
|
_log.info('TransactionsProvider', 'Offline: Transaction will sync when online');
|
|
}
|
|
|
|
return true; // Always return true because it's saved locally
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to create transaction: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Delete a transaction
|
|
Future<bool> deleteTransaction({
|
|
required String accessToken,
|
|
required String transactionId,
|
|
}) async {
|
|
try {
|
|
final isOnline = _connectivityService?.isOnline ?? false;
|
|
|
|
if (isOnline) {
|
|
// Try to delete on server
|
|
final result = await _transactionsService.deleteTransaction(
|
|
accessToken: accessToken,
|
|
transactionId: transactionId,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
// Delete from local storage
|
|
await _offlineStorage.deleteTransactionByServerId(transactionId);
|
|
_transactions.removeWhere((t) => t.id == transactionId);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = result['error'] as String? ?? 'Failed to delete transaction';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} else {
|
|
// Offline - mark for deletion and sync later
|
|
_log.info('TransactionsProvider', 'Offline: Marking transaction for deletion');
|
|
await _offlineStorage.markTransactionForDeletion(transactionId);
|
|
|
|
// Reload from storage to update UI with pending delete status
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to delete transaction: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Delete multiple transactions
|
|
Future<bool> deleteMultipleTransactions({
|
|
required String accessToken,
|
|
required List<String> transactionIds,
|
|
}) async {
|
|
try {
|
|
final isOnline = _connectivityService?.isOnline ?? false;
|
|
|
|
if (isOnline) {
|
|
final result = await _transactionsService.deleteMultipleTransactions(
|
|
accessToken: accessToken,
|
|
transactionIds: transactionIds,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
// Delete from local storage
|
|
for (final id in transactionIds) {
|
|
await _offlineStorage.deleteTransactionByServerId(id);
|
|
}
|
|
_transactions.removeWhere((t) => transactionIds.contains(t.id));
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = result['error'] as String? ?? 'Failed to delete transactions';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} else {
|
|
// Offline - mark all for deletion and sync later
|
|
_log.info('TransactionsProvider', 'Offline: Marking ${transactionIds.length} transactions for deletion');
|
|
for (final id in transactionIds) {
|
|
await _offlineStorage.markTransactionForDeletion(id);
|
|
}
|
|
|
|
// Reload from storage to update UI with pending delete status
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to delete multiple transactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Undo a pending transaction (either pending create or pending delete)
|
|
Future<bool> undoPendingTransaction({
|
|
required String localId,
|
|
required SyncStatus syncStatus,
|
|
}) async {
|
|
_log.info('TransactionsProvider', 'Undoing transaction $localId with status $syncStatus');
|
|
|
|
try {
|
|
final success = await _offlineStorage.undoPendingTransaction(localId, syncStatus);
|
|
|
|
if (success) {
|
|
// Reload from storage to update UI
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = 'Failed to undo transaction';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to undo transaction: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Manually trigger sync
|
|
Future<void> syncTransactions({
|
|
required String accessToken,
|
|
}) async {
|
|
if (_connectivityService?.isOffline == true) {
|
|
_error = 'Cannot sync while offline';
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _syncService.performFullSync(accessToken);
|
|
|
|
if (result.success) {
|
|
// Reload from local storage
|
|
final updatedTransactions = await _offlineStorage.getTransactions();
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
} else {
|
|
_error = result.error;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to sync transactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void clearTransactions() {
|
|
_transactions = [];
|
|
_error = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearError() {
|
|
_error = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
if (_isListenerAttached && _connectivityService != null) {
|
|
_connectivityService!.removeListener(_onConnectivityChanged);
|
|
_isListenerAttached = false;
|
|
}
|
|
_connectivityService = null;
|
|
super.dispose();
|
|
}
|
|
}
|