mirror of
https://github.com/we-promise/sure.git
synced 2026-04-23 14:04:06 +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>
447 lines
16 KiB
Dart
447 lines
16 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import '../models/offline_transaction.dart';
|
|
import '../models/transaction.dart';
|
|
import 'offline_storage_service.dart';
|
|
import 'transactions_service.dart';
|
|
import 'accounts_service.dart';
|
|
import 'connectivity_service.dart';
|
|
import 'log_service.dart';
|
|
|
|
class SyncService with ChangeNotifier {
|
|
final OfflineStorageService _offlineStorage = OfflineStorageService();
|
|
final TransactionsService _transactionsService = TransactionsService();
|
|
final AccountsService _accountsService = AccountsService();
|
|
final LogService _log = LogService.instance;
|
|
|
|
bool _isSyncing = false;
|
|
String? _syncError;
|
|
DateTime? _lastSyncTime;
|
|
|
|
bool get isSyncing => _isSyncing;
|
|
String? get syncError => _syncError;
|
|
DateTime? get lastSyncTime => _lastSyncTime;
|
|
|
|
/// Sync pending deletes to server (internal method without sync lock check)
|
|
Future<SyncResult> _syncPendingDeletesInternal(String accessToken) async {
|
|
int successCount = 0;
|
|
int failureCount = 0;
|
|
String? lastError;
|
|
|
|
try {
|
|
final pendingDeletes = await _offlineStorage.getPendingDeletes();
|
|
_log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process');
|
|
|
|
if (pendingDeletes.isEmpty) {
|
|
return SyncResult(success: true, syncedCount: 0);
|
|
}
|
|
|
|
for (final transaction in pendingDeletes) {
|
|
try {
|
|
// Only attempt to delete on server if the transaction has a server ID
|
|
if (transaction.id != null && transaction.id!.isNotEmpty) {
|
|
_log.info('SyncService', 'Deleting transaction ${transaction.id} from server');
|
|
final result = await _transactionsService.deleteTransaction(
|
|
accessToken: accessToken,
|
|
transactionId: transaction.id!,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
_log.info('SyncService', 'Delete success! Removing from local storage');
|
|
// Delete from local storage completely
|
|
await _offlineStorage.deleteTransaction(transaction.localId);
|
|
successCount++;
|
|
} else {
|
|
// Mark as failed but keep it as pending delete for retry
|
|
_log.error('SyncService', 'Delete failed: ${result['error']}');
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: transaction.localId,
|
|
syncStatus: SyncStatus.failed,
|
|
);
|
|
failureCount++;
|
|
lastError = result['error'] as String?;
|
|
}
|
|
} else {
|
|
// No server ID means it was never synced to server, just delete locally
|
|
_log.info('SyncService', 'Transaction ${transaction.localId} has no server ID, deleting locally only');
|
|
await _offlineStorage.deleteTransaction(transaction.localId);
|
|
successCount++;
|
|
}
|
|
} catch (e) {
|
|
// Mark as failed
|
|
_log.error('SyncService', 'Delete exception: $e');
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: transaction.localId,
|
|
syncStatus: SyncStatus.failed,
|
|
);
|
|
failureCount++;
|
|
lastError = e.toString();
|
|
}
|
|
}
|
|
|
|
_log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed');
|
|
|
|
return SyncResult(
|
|
success: failureCount == 0,
|
|
syncedCount: successCount,
|
|
failedCount: failureCount,
|
|
error: failureCount > 0 ? lastError : null,
|
|
);
|
|
} catch (e) {
|
|
_log.error('SyncService', 'Sync pending deletes exception: $e');
|
|
return SyncResult(
|
|
success: false,
|
|
syncedCount: successCount,
|
|
failedCount: failureCount,
|
|
error: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Sync pending transactions to server (internal method without sync lock check)
|
|
Future<SyncResult> _syncPendingTransactionsInternal(String accessToken) async {
|
|
int successCount = 0;
|
|
int failureCount = 0;
|
|
String? lastError;
|
|
|
|
try {
|
|
final pendingTransactions = await _offlineStorage.getPendingTransactions();
|
|
_log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload');
|
|
|
|
if (pendingTransactions.isEmpty) {
|
|
return SyncResult(success: true, syncedCount: 0);
|
|
}
|
|
|
|
for (final transaction in pendingTransactions) {
|
|
try {
|
|
_log.info('SyncService', 'Uploading transaction ${transaction.localId} (${transaction.name})');
|
|
// Upload transaction to server
|
|
final result = await _transactionsService.createTransaction(
|
|
accessToken: accessToken,
|
|
accountId: transaction.accountId,
|
|
name: transaction.name,
|
|
date: transaction.date,
|
|
amount: transaction.amount,
|
|
currency: transaction.currency,
|
|
nature: transaction.nature,
|
|
notes: transaction.notes,
|
|
categoryId: transaction.categoryId,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
// Update local transaction with server ID and mark as synced
|
|
final serverTransaction = result['transaction'] as Transaction;
|
|
_log.info('SyncService', 'Upload success! Server ID: ${serverTransaction.id}');
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: transaction.localId,
|
|
syncStatus: SyncStatus.synced,
|
|
serverId: serverTransaction.id,
|
|
);
|
|
successCount++;
|
|
} else {
|
|
// Mark as failed
|
|
_log.error('SyncService', 'Upload failed: ${result['error']}');
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: transaction.localId,
|
|
syncStatus: SyncStatus.failed,
|
|
);
|
|
failureCount++;
|
|
lastError = result['error'] as String?;
|
|
}
|
|
} catch (e) {
|
|
// Mark as failed
|
|
_log.error('SyncService', 'Upload exception: $e');
|
|
await _offlineStorage.updateTransactionSyncStatus(
|
|
localId: transaction.localId,
|
|
syncStatus: SyncStatus.failed,
|
|
);
|
|
failureCount++;
|
|
lastError = e.toString();
|
|
}
|
|
}
|
|
|
|
_log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed');
|
|
|
|
return SyncResult(
|
|
success: failureCount == 0,
|
|
syncedCount: successCount,
|
|
failedCount: failureCount,
|
|
error: failureCount > 0 ? lastError : null,
|
|
);
|
|
} catch (e) {
|
|
_log.error('SyncService', 'Sync pending transactions exception: $e');
|
|
return SyncResult(
|
|
success: false,
|
|
syncedCount: successCount,
|
|
failedCount: failureCount,
|
|
error: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Sync pending transactions to server
|
|
Future<SyncResult> syncPendingTransactions(String accessToken) async {
|
|
if (_isSyncing) {
|
|
return SyncResult(success: false, error: 'Sync already in progress');
|
|
}
|
|
|
|
_log.info('SyncService', 'syncPendingTransactions started');
|
|
_isSyncing = true;
|
|
_syncError = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _syncPendingTransactionsInternal(accessToken);
|
|
|
|
_isSyncing = false;
|
|
_lastSyncTime = DateTime.now();
|
|
_syncError = result.success ? null : result.error;
|
|
notifyListeners();
|
|
|
|
return result;
|
|
} catch (e) {
|
|
_log.error('SyncService', 'syncPendingTransactions exception: $e');
|
|
_isSyncing = false;
|
|
_syncError = e.toString();
|
|
notifyListeners();
|
|
|
|
return SyncResult(
|
|
success: false,
|
|
error: _syncError,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Download transactions from server and update local cache
|
|
Future<SyncResult> syncFromServer({
|
|
required String accessToken,
|
|
String? accountId,
|
|
}) async {
|
|
try {
|
|
_log.info('SyncService', '========== SYNC FROM SERVER START ==========');
|
|
_log.info('SyncService', 'Fetching transactions from server (accountId: ${accountId ?? "ALL"})');
|
|
|
|
List<Transaction> allTransactions = [];
|
|
int currentPage = 1;
|
|
int totalPages = 1;
|
|
const int perPage = 100; // Use maximum allowed by backend
|
|
|
|
// Fetch all pages
|
|
while (currentPage <= totalPages) {
|
|
_log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)');
|
|
|
|
final result = await _transactionsService.getTransactions(
|
|
accessToken: accessToken,
|
|
accountId: accountId,
|
|
page: currentPage,
|
|
perPage: perPage,
|
|
);
|
|
|
|
_log.debug('SyncService', 'API call completed for page $currentPage, success: ${result['success']}');
|
|
|
|
if (result['success'] == true) {
|
|
final pageTransactions = (result['transactions'] as List<dynamic>?)
|
|
?.cast<Transaction>() ?? [];
|
|
|
|
_log.info('SyncService', 'Page $currentPage returned ${pageTransactions.length} transactions');
|
|
allTransactions.addAll(pageTransactions);
|
|
_log.info('SyncService', 'Total transactions accumulated: ${allTransactions.length}');
|
|
|
|
// Extract pagination info if available
|
|
final pagination = result['pagination'] as Map<String, dynamic>?;
|
|
if (pagination != null) {
|
|
final prevTotalPages = totalPages;
|
|
totalPages = pagination['total_pages'] as int? ?? 1;
|
|
final totalCount = pagination['total_count'] as int? ?? 0;
|
|
final currentPageFromApi = pagination['page'] as int? ?? currentPage;
|
|
final perPageFromApi = pagination['per_page'] as int? ?? perPage;
|
|
|
|
_log.info('SyncService', 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount');
|
|
|
|
if (prevTotalPages != totalPages) {
|
|
_log.info('SyncService', 'Total pages updated from $prevTotalPages to $totalPages');
|
|
}
|
|
} else {
|
|
// No pagination info means this is the only page
|
|
_log.warning('SyncService', 'No pagination info in response - assuming single page');
|
|
totalPages = currentPage;
|
|
}
|
|
|
|
_log.info('SyncService', 'Moving to next page (current: $currentPage, total: $totalPages)');
|
|
currentPage++;
|
|
} else {
|
|
_log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}');
|
|
return SyncResult(
|
|
success: false,
|
|
error: result['error'] as String? ?? 'Failed to sync from server',
|
|
);
|
|
}
|
|
}
|
|
|
|
_log.info('SyncService', '>>> Pagination loop completed. Fetched ${currentPage - 1} pages');
|
|
_log.info('SyncService', '>>> Received total of ${allTransactions.length} transactions from server');
|
|
|
|
// Update local cache with server data
|
|
_log.info('SyncService', '========== UPDATING LOCAL CACHE ==========');
|
|
if (accountId == null) {
|
|
_log.info('SyncService', 'Full sync - clearing and replacing all transactions');
|
|
// Full sync - replace all transactions
|
|
await _offlineStorage.syncTransactionsFromServer(allTransactions);
|
|
} else {
|
|
_log.info('SyncService', 'Partial sync - upserting ${allTransactions.length} transactions for account $accountId');
|
|
// Partial sync - upsert transactions
|
|
int upsertCount = 0;
|
|
for (final transaction in allTransactions) {
|
|
await _offlineStorage.upsertTransactionFromServer(
|
|
transaction,
|
|
accountId: accountId,
|
|
);
|
|
upsertCount++;
|
|
if (upsertCount % 50 == 0) {
|
|
_log.info('SyncService', 'Upserted $upsertCount/${allTransactions.length} transactions');
|
|
}
|
|
}
|
|
_log.info('SyncService', 'Completed upserting $upsertCount transactions');
|
|
}
|
|
|
|
_log.info('SyncService', '========== SYNC FROM SERVER COMPLETE ==========');
|
|
_lastSyncTime = DateTime.now();
|
|
notifyListeners();
|
|
|
|
return SyncResult(
|
|
success: true,
|
|
syncedCount: allTransactions.length,
|
|
);
|
|
} catch (e) {
|
|
_log.error('SyncService', 'Exception in syncFromServer: $e');
|
|
return SyncResult(
|
|
success: false,
|
|
error: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Sync accounts from server and update local cache
|
|
Future<SyncResult> syncAccounts(String accessToken) async {
|
|
try {
|
|
final result = await _accountsService.getAccounts(accessToken: accessToken);
|
|
|
|
if (result['success'] == true) {
|
|
final accountsList = result['accounts'] as List<dynamic>? ?? [];
|
|
|
|
// Clear and update local account cache
|
|
await _offlineStorage.clearAccounts();
|
|
|
|
// The accounts list contains Account objects, not raw JSON
|
|
for (final account in accountsList) {
|
|
await _offlineStorage.saveAccount(account);
|
|
}
|
|
|
|
notifyListeners();
|
|
|
|
return SyncResult(
|
|
success: true,
|
|
syncedCount: accountsList.length,
|
|
);
|
|
} else {
|
|
return SyncResult(
|
|
success: false,
|
|
error: result['error'] as String? ?? 'Failed to sync accounts',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
return SyncResult(
|
|
success: false,
|
|
error: e.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Full sync - upload pending transactions, process pending deletes, and download from server
|
|
Future<SyncResult> performFullSync(String accessToken) async {
|
|
if (_isSyncing) {
|
|
return SyncResult(success: false, error: 'Sync already in progress');
|
|
}
|
|
|
|
_log.info('SyncService', '==== Full Sync Started ====');
|
|
_isSyncing = true;
|
|
_syncError = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Step 1: Process pending deletes (do this first to free up resources)
|
|
_log.info('SyncService', 'Step 1: Processing pending deletes');
|
|
final deleteResult = await _syncPendingDeletesInternal(accessToken);
|
|
_log.info('SyncService', 'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed');
|
|
|
|
// Step 2: Upload pending transactions
|
|
_log.info('SyncService', 'Step 2: Uploading pending transactions');
|
|
final uploadResult = await _syncPendingTransactionsInternal(accessToken);
|
|
_log.info('SyncService', 'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed');
|
|
|
|
// Step 3: Download transactions from server
|
|
_log.info('SyncService', 'Step 3: Downloading transactions from server');
|
|
final downloadResult = await syncFromServer(accessToken: accessToken);
|
|
_log.info('SyncService', 'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded');
|
|
|
|
// Step 4: Sync accounts
|
|
_log.info('SyncService', 'Step 4: Syncing accounts');
|
|
final accountsResult = await syncAccounts(accessToken);
|
|
_log.info('SyncService', 'Step 4 complete');
|
|
|
|
_isSyncing = false;
|
|
_lastSyncTime = DateTime.now();
|
|
|
|
final allSuccess = deleteResult.success && uploadResult.success && downloadResult.success && accountsResult.success;
|
|
_syncError = allSuccess ? null : (deleteResult.error ?? uploadResult.error ?? downloadResult.error ?? accountsResult.error);
|
|
|
|
_log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ====');
|
|
|
|
notifyListeners();
|
|
|
|
return SyncResult(
|
|
success: allSuccess,
|
|
syncedCount: (deleteResult.syncedCount ?? 0) + (uploadResult.syncedCount ?? 0) + (downloadResult.syncedCount ?? 0),
|
|
failedCount: (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0),
|
|
error: _syncError,
|
|
);
|
|
} catch (e) {
|
|
_log.error('SyncService', 'Full sync exception: $e');
|
|
_isSyncing = false;
|
|
_syncError = e.toString();
|
|
notifyListeners();
|
|
|
|
return SyncResult(
|
|
success: false,
|
|
error: _syncError,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Auto sync if online - to be called when app regains connectivity
|
|
Future<void> autoSync(String accessToken, ConnectivityService connectivityService) async {
|
|
if (connectivityService.isOnline && !_isSyncing) {
|
|
await performFullSync(accessToken);
|
|
}
|
|
}
|
|
|
|
void clearSyncError() {
|
|
_syncError = null;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
class SyncResult {
|
|
final bool success;
|
|
final int? syncedCount;
|
|
final int? failedCount;
|
|
final String? error;
|
|
|
|
SyncResult({
|
|
required this.success,
|
|
this.syncedCount,
|
|
this.failedCount,
|
|
this.error,
|
|
});
|
|
}
|