diff --git a/mobile/docs/OFFLINE_FEATURES.md b/mobile/docs/OFFLINE_FEATURES.md new file mode 100644 index 000000000..2ab80a79c --- /dev/null +++ b/mobile/docs/OFFLINE_FEATURES.md @@ -0,0 +1,354 @@ +# Offline Features Documentation + +## Overview + +The Sure mobile app implements a comprehensive offline-first architecture that allows users to continue using the app even when they don't have an internet connection. All transactions created offline are automatically synced to the server when the connection is restored. + +## Key Features + +### 1. Offline Data Storage + +- **Local SQLite Database**: All transaction and account data is stored locally using SQLite +- **Automatic Caching**: Server data is automatically cached for offline access +- **Persistent Storage**: Data persists across app restarts + +### 2. Offline Transaction Management + +- **Create Transactions Offline**: Users can create new transactions even without internet +- **View Cached Data**: Access previously synced transactions and accounts offline +- **Pending Sync Indicator**: Transactions created offline are marked as "pending" until synced + +### 3. Automatic Synchronization + +- **Network Detection**: App automatically detects when network connectivity is restored +- **Background Sync**: Pending transactions are automatically uploaded when online +- **Server Data Download**: Latest server data is downloaded and cached locally +- **Conflict Resolution**: Server data takes precedence during sync + +### 4. Visual Indicators + +- **Connectivity Banner**: Shows current connection status and pending transaction count +- **Sync Status Badges**: Individual transactions show their sync status (pending/failed/synced) +- **Manual Sync Button**: Users can trigger sync manually when online + +## Architecture + +### Data Flow + +``` +┌─────────────────┐ +│ User Interface │ +└────────┬────────┘ + │ + ▼ +┌─────────────────────────┐ +│ TransactionsProvider │ +│ (State Management) │ +└────────┬───────┬────────┘ + │ │ + │ ▼ + │ ┌──────────────────────┐ + │ │ ConnectivityService │ + │ │ (Network Detection) │ + │ └──────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ OfflineStorageService │ +│ (Local SQLite DB) │ +└─────────────────────────┘ + ▲ + │ + ▼ +┌─────────────────────────┐ +│ SyncService │ +│ (Server Sync Logic) │ +└────────┬────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ TransactionsService │ +│ (HTTP API Client) │ +└─────────────────────────┘ +``` + +### Database Schema + +#### Transactions Table +```sql +CREATE TABLE transactions ( + local_id TEXT PRIMARY KEY, -- UUID generated locally + server_id TEXT, -- Server ID after sync + 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, + sync_status TEXT NOT NULL, -- 'synced', 'pending', 'failed' + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) +``` + +#### Accounts Table (Cache) +```sql +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 +) +``` + +## Components + +### Services + +#### 1. ConnectivityService (`lib/services/connectivity_service.dart`) +- Monitors network connectivity status +- Provides real-time connectivity updates +- Uses `connectivity_plus` package + +#### 2. DatabaseHelper (`lib/services/database_helper.dart`) +- Manages SQLite database operations +- Handles table creation and migrations +- Provides CRUD operations for local data + +#### 3. OfflineStorageService (`lib/services/offline_storage_service.dart`) +- High-level API for offline data management +- Converts between app models and database records +- Manages transaction sync status + +#### 4. SyncService (`lib/services/sync_service.dart`) +- Coordinates data synchronization with server +- Uploads pending transactions +- Downloads and caches server data +- Handles sync errors and retries + +### Models + +#### OfflineTransaction (`lib/models/offline_transaction.dart`) +```dart +class OfflineTransaction extends Transaction { + final String localId; // Local UUID + final SyncStatus syncStatus; // Sync state + final DateTime createdAt; // Local creation time + final DateTime updatedAt; // Last update time +} + +enum SyncStatus { + synced, // Successfully synced with server + pending, // Waiting to be synced + failed, // Last sync attempt failed +} +``` + +### UI Components + +#### 1. ConnectivityBanner (`lib/widgets/connectivity_banner.dart`) +- Displays at top of screen when offline or has pending transactions +- Shows "Sync Now" button when online with pending items +- Auto-hides when online and all synced + +#### 2. SyncStatusBadge (`lib/widgets/sync_status_badge.dart`) +- Shows sync status for individual transactions +- Compact mode for list items +- Full mode for transaction details + +## Usage Examples + +### Creating a Transaction Offline + +```dart +final transactionsProvider = Provider.of(context, listen: false); +final authProvider = Provider.of(context, listen: false); + +await transactionsProvider.createTransaction( + accessToken: authProvider.tokens!.accessToken, + accountId: account.id, + name: 'Coffee', + date: '2024-01-15', + amount: '5.50', + currency: 'USD', + nature: 'expense', + notes: 'Morning coffee', +); + +// Transaction is saved locally with status 'pending' +// Will auto-sync when connection is restored +``` + +### Manual Sync + +```dart +final transactionsProvider = Provider.of(context, listen: false); +final authProvider = Provider.of(context, listen: false); + +await transactionsProvider.syncTransactions( + accessToken: authProvider.tokens!.accessToken, +); +``` + +### Checking Connectivity Status + +```dart +final connectivityService = Provider.of(context); + +if (connectivityService.isOnline) { + // App is online +} else { + // App is offline +} +``` + +### Checking Pending Transactions + +```dart +final transactionsProvider = Provider.of(context); + +if (transactionsProvider.hasPendingTransactions) { + print('Pending count: ${transactionsProvider.pendingCount}'); +} +``` + +## Sync Behavior + +### When Creating a Transaction + +1. **Online**: + - Attempts to create on server immediately + - On success: Saves to local DB with status 'synced' + - On failure: Saves to local DB with status 'pending' + +2. **Offline**: + - Saves to local DB with status 'pending' + - Shows success to user (transaction is saved locally) + - Will sync automatically when connection restored + +### When Loading Transactions + +1. **Always** loads from local SQLite first (instant display) +2. **If online** and local is empty, syncs from server +3. **If force refresh**, syncs from server and updates local cache + +### Automatic Sync + +The app automatically syncs in these scenarios: +- App starts with internet connection +- Network connection is restored after being offline +- User manually triggers sync via "Sync Now" button +- User pulls to refresh on dashboard or transaction list + +### Sync Process + +1. **Upload Phase**: + - Gets all pending transactions from local DB + - Uploads each to server sequentially + - Updates local records with server IDs on success + - Marks as 'failed' if upload fails + +2. **Download Phase**: + - Fetches all transactions from server + - Updates local cache with server data + - Server data takes precedence over local changes + +3. **Account Sync**: + - Updates local account cache with latest balances + - Ensures account dropdown has current data + +## Error Handling + +### Network Errors +- Transactions remain marked as 'pending' +- User can retry sync manually +- Visual indicator shows sync failure + +### Sync Conflicts +- Server data always takes precedence +- Local pending transactions are uploaded first +- Then server data is downloaded and cached + +### Database Errors +- Errors are logged and reported to user +- App continues to function with potentially stale data +- User can force refresh to retry + +## Testing Offline Functionality + +### Simulating Offline Mode + +1. **Android Emulator**: + - Swipe down notification panel + - Toggle Airplane Mode + +2. **iOS Simulator**: + - Settings → Airplane Mode → ON + +3. **Physical Device**: + - Enable Airplane Mode in device settings + +### Test Scenarios + +1. **Create Transaction Offline**: + - Turn on airplane mode + - Create a new transaction + - Verify it appears in the list with "pending" badge + - Turn off airplane mode + - Verify automatic sync occurs + +2. **View Cached Data**: + - Use app while online + - Turn on airplane mode + - Verify all previously viewed data is still accessible + +3. **Manual Sync**: + - Create transactions offline + - Turn off airplane mode + - Tap "Sync Now" button + - Verify transactions sync successfully + +## Performance Considerations + +- **Database Size**: SQLite can handle millions of records efficiently +- **Sync Batching**: Pending transactions are uploaded sequentially to avoid overwhelming the server +- **Cache Invalidation**: Account cache is refreshed on each sync to ensure accurate balances +- **Memory Usage**: Only active transactions are kept in memory; database queries are paginated + +## Future Enhancements + +Potential improvements for future versions: + +1. **Conflict Resolution UI**: Allow users to choose which version to keep when conflicts occur +2. **Selective Sync**: Sync only specific accounts or date ranges +3. **Background Sync**: Use platform background tasks for periodic syncing +4. **Offline Editing**: Support editing transactions offline +5. **Offline Deletion**: Support deleting transactions offline with sync +6. **Export Offline Data**: Export local database for backup +7. **Data Compression**: Compress large sync payloads for better performance + +## Troubleshooting + +### Transactions Not Syncing + +1. Check internet connection +2. Verify you're logged in (tokens are valid) +3. Check sync status in app (ConnectivityBanner) +4. Try manual sync via "Sync Now" button +5. Check server logs for API errors + +### Database Issues + +1. Clear app data (will lose offline transactions) +2. Reinstall app +3. Contact support if issue persists + +### Performance Issues + +1. Check device storage (database needs space) +2. Consider clearing old synced transactions +3. Reduce number of accounts if possible diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 36b71a378..07c587700 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,10 +7,16 @@ import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; import 'screens/dashboard_screen.dart'; import 'services/api_config.dart'; +import 'services/connectivity_service.dart'; +import 'services/log_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await ApiConfig.initialize(); + + // Add initial log entry + LogService.instance.info('App', 'Sure Finance app starting...'); + runApp(const SureApp()); } @@ -21,9 +27,35 @@ class SureApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider(create: (_) => LogService.instance), + ChangeNotifierProvider(create: (_) => ConnectivityService()), ChangeNotifierProvider(create: (_) => AuthProvider()), - ChangeNotifierProvider(create: (_) => AccountsProvider()), - ChangeNotifierProvider(create: (_) => TransactionsProvider()), + ChangeNotifierProxyProvider( + create: (_) => AccountsProvider(), + update: (_, connectivityService, accountsProvider) { + if (accountsProvider == null) { + final provider = AccountsProvider(); + provider.setConnectivityService(connectivityService); + return provider; + } else { + accountsProvider.setConnectivityService(connectivityService); + return accountsProvider; + } + }, + ), + ChangeNotifierProxyProvider( + create: (_) => TransactionsProvider(), + update: (_, connectivityService, transactionsProvider) { + if (transactionsProvider == null) { + final provider = TransactionsProvider(); + provider.setConnectivityService(connectivityService); + return provider; + } else { + transactionsProvider.setConnectivityService(connectivityService); + return transactionsProvider; + } + }, + ), ], child: MaterialApp( title: 'Sure Finance', diff --git a/mobile/lib/models/offline_transaction.dart b/mobile/lib/models/offline_transaction.dart new file mode 100644 index 000000000..4259f40a3 --- /dev/null +++ b/mobile/lib/models/offline_transaction.dart @@ -0,0 +1,159 @@ +import 'transaction.dart'; + +enum SyncStatus { + synced, // Transaction is synced with server + pending, // Transaction is waiting to be synced (create) + failed, // Last sync attempt failed + pendingDelete, // Transaction is waiting to be deleted on server +} + +class OfflineTransaction extends Transaction { + final String localId; + final SyncStatus syncStatus; + final DateTime createdAt; + final DateTime updatedAt; + + OfflineTransaction({ + super.id, + required this.localId, + required super.accountId, + required super.name, + required super.date, + required super.amount, + required super.currency, + required super.nature, + super.notes, + this.syncStatus = SyncStatus.pending, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + factory OfflineTransaction.fromTransaction( + Transaction transaction, { + required String localId, + SyncStatus syncStatus = SyncStatus.synced, + }) { + return OfflineTransaction( + id: transaction.id, + localId: localId, + accountId: transaction.accountId, + name: transaction.name, + date: transaction.date, + amount: transaction.amount, + currency: transaction.currency, + nature: transaction.nature, + notes: transaction.notes, + syncStatus: syncStatus, + ); + } + + factory OfflineTransaction.fromDatabaseMap(Map map) { + return OfflineTransaction( + id: map['server_id'] as String?, + localId: map['local_id'] as String, + accountId: map['account_id'] as String, + name: map['name'] as String, + date: map['date'] as String, + amount: map['amount'] as String, + currency: map['currency'] as String, + nature: map['nature'] as String, + notes: map['notes'] as String?, + syncStatus: _parseSyncStatus(map['sync_status'] as String), + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toDatabaseMap() { + return { + 'local_id': localId, + 'server_id': id, + 'account_id': accountId, + 'name': name, + 'date': date, + 'amount': amount, + 'currency': currency, + 'nature': nature, + 'notes': notes, + 'sync_status': _syncStatusToString(syncStatus), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + Transaction toTransaction() { + return Transaction( + id: id, + accountId: accountId, + name: name, + date: date, + amount: amount, + currency: currency, + nature: nature, + notes: notes, + ); + } + + OfflineTransaction copyWith({ + String? id, + String? localId, + String? accountId, + String? name, + String? date, + String? amount, + String? currency, + String? nature, + String? notes, + SyncStatus? syncStatus, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return OfflineTransaction( + id: id ?? this.id, + localId: localId ?? this.localId, + accountId: accountId ?? this.accountId, + name: name ?? this.name, + date: date ?? this.date, + amount: amount ?? this.amount, + currency: currency ?? this.currency, + nature: nature ?? this.nature, + notes: notes ?? this.notes, + syncStatus: syncStatus ?? this.syncStatus, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + bool get isSynced => syncStatus == SyncStatus.synced && id != null; + bool get isPending => syncStatus == SyncStatus.pending; + bool get hasFailed => syncStatus == SyncStatus.failed; + + static SyncStatus _parseSyncStatus(String status) { + switch (status) { + case 'synced': + return SyncStatus.synced; + case 'pending': + return SyncStatus.pending; + case 'failed': + return SyncStatus.failed; + case 'pending_delete': + return SyncStatus.pendingDelete; + default: + return SyncStatus.pending; + } + } + + static String _syncStatusToString(SyncStatus status) { + switch (status) { + case SyncStatus.synced: + return 'synced'; + case SyncStatus.pending: + return 'pending'; + case SyncStatus.failed: + return 'failed'; + case SyncStatus.pendingDelete: + return 'pending_delete'; + } + } +} diff --git a/mobile/lib/providers/accounts_provider.dart b/mobile/lib/providers/accounts_provider.dart index b5d465443..efa6e65bd 100644 --- a/mobile/lib/providers/accounts_provider.dart +++ b/mobile/lib/providers/accounts_provider.dart @@ -1,19 +1,27 @@ +import 'dart:io'; +import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/account.dart'; import '../services/accounts_service.dart'; +import '../services/offline_storage_service.dart'; +import '../services/connectivity_service.dart'; +import '../services/log_service.dart'; class AccountsProvider with ChangeNotifier { final AccountsService _accountsService = AccountsService(); + final OfflineStorageService _offlineStorage = OfflineStorageService(); + final LogService _log = LogService.instance; List _accounts = []; bool _isLoading = false; - bool _isInitializing = true; // Track if we've fetched accounts at least once + bool _isInitializing = true; String? _errorMessage; Map? _pagination; + ConnectivityService? _connectivityService; List get accounts => _accounts; bool get isLoading => _isLoading; - bool get isInitializing => _isInitializing; // Expose initialization state + bool get isInitializing => _isInitializing; String? get errorMessage => _errorMessage; Map? get pagination => _pagination; @@ -45,6 +53,10 @@ class AccountsProvider with ChangeNotifier { return totals; } + void setConnectivityService(ConnectivityService service) { + _connectivityService = service; + } + void _sortAccounts(List accounts) { accounts.sort((a, b) { // 1. Sort by account type @@ -64,42 +76,91 @@ class AccountsProvider with ChangeNotifier { }); } + /// Fetch accounts (offline-first approach) Future fetchAccounts({ required String accessToken, int page = 1, int perPage = 25, + bool forceSync = false, }) async { _isLoading = true; _errorMessage = null; notifyListeners(); try { - final result = await _accountsService.getAccounts( - accessToken: accessToken, - page: page, - perPage: perPage, - ); - - if (result['success'] == true && result.containsKey('accounts')) { - _accounts = (result['accounts'] as List?)?.cast() ?? []; - _pagination = result['pagination'] as Map?; - _isLoading = false; - _isInitializing = false; // Mark as initialized after first fetch + // Always load from local storage first for instant display + final cachedAccounts = await _offlineStorage.getAccounts(); + if (cachedAccounts.isNotEmpty) { + _accounts = cachedAccounts; + _isInitializing = false; notifyListeners(); - return true; - } else { - _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts'; - _isLoading = false; - _isInitializing = false; // Mark as initialized even on error - notifyListeners(); - return false; } - } catch (e) { - _errorMessage = 'Connection error. Please check your internet connection.'; + + // If online and (force sync or no cached data), fetch from server + final isOnline = _connectivityService?.isOnline ?? false; + if (isOnline && (forceSync || cachedAccounts.isEmpty)) { + final result = await _accountsService.getAccounts( + accessToken: accessToken, + page: page, + perPage: perPage, + ); + + if (result['success'] == true && result.containsKey('accounts')) { + final serverAccounts = (result['accounts'] as List?)?.cast() ?? []; + _pagination = result['pagination'] as Map?; + + // Save to local cache + await _offlineStorage.clearAccounts(); + await _offlineStorage.saveAccounts(serverAccounts); + + // Update in-memory accounts + _accounts = serverAccounts; + _errorMessage = null; + } else { + // If server fetch failed but we have cached data, that's OK + if (_accounts.isEmpty) { + _errorMessage = result['error'] as String? ?? 'Failed to fetch accounts'; + } + } + } else if (!isOnline && _accounts.isEmpty) { + _errorMessage = 'You are offline. Please connect to the internet to load accounts.'; + } + _isLoading = false; - _isInitializing = false; // Mark as initialized even on error + _isInitializing = false; notifyListeners(); - return false; + return _accounts.isNotEmpty; + } catch (e) { + _log.error('AccountsProvider', 'Error in fetchAccounts: $e'); + // If we have cached accounts, show them even if sync fails + if (_accounts.isEmpty) { + // Provide more specific error messages based on exception type + if (e is SocketException) { + _errorMessage = 'Network error. Please check your internet connection and try again.'; + _log.error('AccountsProvider', 'SocketException: $e'); + } else if (e is TimeoutException) { + _errorMessage = 'Request timed out. Please check your connection and try again.'; + _log.error('AccountsProvider', 'TimeoutException: $e'); + } else if (e is FormatException) { + _errorMessage = 'Server response error. Please try again later.'; + _log.error('AccountsProvider', 'FormatException: $e'); + } else if (e.toString().contains('401') || e.toString().contains('unauthorized')) { + _errorMessage = 'unauthorized'; + _log.error('AccountsProvider', 'Unauthorized error: $e'); + } else if (e.toString().contains('HandshakeException') || + e.toString().contains('certificate') || + e.toString().contains('SSL')) { + _errorMessage = 'Secure connection error. Please check your internet connection and try again.'; + _log.error('AccountsProvider', 'SSL/Certificate error: $e'); + } else { + _errorMessage = 'Something went wrong. Please try again.'; + _log.error('AccountsProvider', 'Unhandled exception: $e'); + } + } + _isLoading = false; + _isInitializing = false; + notifyListeners(); + return _accounts.isNotEmpty; } } @@ -107,7 +168,7 @@ class AccountsProvider with ChangeNotifier { _accounts = []; _pagination = null; _errorMessage = null; - _isInitializing = true; // Reset initialization state on clear + _isInitializing = true; notifyListeners(); } diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 24b0a4e6f..8e58589e0 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -3,6 +3,7 @@ import '../models/user.dart'; import '../models/auth_tokens.dart'; import '../services/auth_service.dart'; import '../services/device_service.dart'; +import '../services/log_service.dart'; class AuthProvider with ChangeNotifier { final AuthService _authService = AuthService(); @@ -75,7 +76,7 @@ class AuthProvider with ChangeNotifier { otpCode: otpCode, ); - debugPrint('Login result: $result'); // Debug log + LogService.instance.debug('AuthProvider', 'Login result: $result'); if (result['success'] == true) { _tokens = result['tokens'] as AuthTokens?; @@ -89,7 +90,7 @@ class AuthProvider with ChangeNotifier { if (result['mfa_required'] == true) { _mfaRequired = true; _showMfaInput = true; // Show MFA input field - debugPrint('MFA required! Setting _showMfaInput to true'); // Debug log + LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true'); // If user already submitted an OTP code, this is likely an invalid OTP error // Show the error message so user knows the code was wrong diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 5e4f2f01c..9af195889 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -1,87 +1,403 @@ 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 _transactions = []; + List _transactions = []; bool _isLoading = false; String? _error; + ConnectivityService? _connectivityService; + String? _lastAccessToken; + bool _isAutoSyncing = false; + bool _isListenerAttached = false; + bool _isDisposed = false; + + List get transactions => + UnmodifiableListView(_transactions.map((t) => t.toTransaction())); + + List get offlineTransactions => + UnmodifiableListView(_transactions); - List get transactions => 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 fetchTransactions({ required String accessToken, String? accountId, + bool forceSync = false, }) async { + _lastAccessToken = accessToken; // Store for auto-sync _isLoading = true; _error = null; notifyListeners(); - final result = await _transactionsService.getTransactions( - accessToken: accessToken, - accountId: accountId, - ); + try { + // Always load from local storage first + final localTransactions = await _offlineStorage.getTransactions( + accountId: accountId, + ); - _isLoading = false; + _log.debug('TransactionsProvider', 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)'); - if (result['success'] == true && result.containsKey('transactions')) { - _transactions = (result['transactions'] as List?)?.cast() ?? []; - _error = null; - } else { - _error = result['error'] as String? ?? 'Failed to fetch transactions'; + _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(); } - - notifyListeners(); } + /// Create a new transaction (offline-first) + Future createTransaction({ + required String accessToken, + required String accountId, + required String name, + required String date, + required String amount, + required String currency, + required String nature, + String? notes, + }) 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, + 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, + ).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 deleteTransaction({ required String accessToken, required String transactionId, }) async { - final result = await _transactionsService.deleteTransaction( - accessToken: accessToken, - transactionId: transactionId, - ); + try { + final isOnline = _connectivityService?.isOnline ?? false; - if (result['success'] == true) { - _transactions.removeWhere((t) => t.id == transactionId); - notifyListeners(); - return true; - } else { - _error = result['error'] as String? ?? 'Failed to delete transaction'; + 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(); + _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 deleteMultipleTransactions({ required String accessToken, required List transactionIds, }) async { - final result = await _transactionsService.deleteMultipleTransactions( - accessToken: accessToken, - transactionIds: transactionIds, - ); + try { + final isOnline = _connectivityService?.isOnline ?? false; - if (result['success'] == true) { - _transactions.removeWhere((t) => transactionIds.contains(t.id)); - notifyListeners(); - return true; - } else { - _error = result['error'] as String? ?? 'Failed to delete transactions'; + 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(); + _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 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(); + _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 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(); + } } diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index ca2afd5fd..e62717c40 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -3,9 +3,13 @@ import 'package:provider/provider.dart'; import '../models/account.dart'; import '../providers/auth_provider.dart'; import '../providers/accounts_provider.dart'; +import '../providers/transactions_provider.dart'; +import '../services/log_service.dart'; import '../widgets/account_card.dart'; +import '../widgets/connectivity_banner.dart'; import 'transaction_form_screen.dart'; import 'transactions_list_screen.dart'; +import 'log_viewer_screen.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -15,13 +19,58 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { + final LogService _log = LogService.instance; bool _assetsExpanded = true; bool _liabilitiesExpanded = true; + bool _showSyncSuccess = false; + int _previousPendingCount = 0; + TransactionsProvider? _transactionsProvider; @override void initState() { super.initState(); _loadAccounts(); + + // Listen for sync completion to show success indicator + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _transactionsProvider = Provider.of(context, listen: false); + _previousPendingCount = _transactionsProvider?.pendingCount ?? 0; + _transactionsProvider?.addListener(_onTransactionsChanged); + }); + } + + @override + void dispose() { + _transactionsProvider?.removeListener(_onTransactionsChanged); + super.dispose(); + } + + void _onTransactionsChanged() { + final transactionsProvider = _transactionsProvider; + if (transactionsProvider == null || !mounted) { + return; + } + + final currentPendingCount = transactionsProvider.pendingCount; + + // If pending count decreased, it means transactions were synced + if (_previousPendingCount > 0 && currentPendingCount < _previousPendingCount) { + setState(() { + _showSyncSuccess = true; + }); + + // Hide the success indicator after 3 seconds + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + setState(() { + _showSyncSuccess = false; + }); + } + }); + } + + _previousPendingCount = currentPendingCount; } Future _loadAccounts() async { @@ -44,7 +93,84 @@ class _DashboardScreenState extends State { } Future _handleRefresh() async { - await _loadAccounts(); + await _performManualSync(); + } + + Future _performManualSync() async { + final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + // Show syncing indicator + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + SizedBox(width: 12), + Text('Syncing data from server...'), + ], + ), + duration: Duration(seconds: 30), + ), + ); + } + + try { + // Perform full sync: upload pending, download from server, sync accounts + await transactionsProvider.syncTransactions(accessToken: accessToken); + + // Reload accounts to show updated balances + await _loadAccounts(); + + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 12), + Text('Sync completed successfully'), + ], + ), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + _log.error('DashboardScreen', 'Error in _performManualSync: $e'); + if (mounted) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + Icon(Icons.error, color: Colors.white), + SizedBox(width: 12), + Expanded(child: Text('Sync failed. Please try again.')), + ], + ), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + } } List _formatCurrencyItem(String currency, double amount) { @@ -193,6 +319,33 @@ class _DashboardScreenState extends State { appBar: AppBar( title: const Text('Dashboard'), actions: [ + if (_showSyncSuccess) + Padding( + padding: const EdgeInsets.only(right: 8), + child: AnimatedOpacity( + opacity: _showSyncSuccess ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: const Icon( + Icons.cloud_done, + color: Colors.green, + size: 28, + ), + ), + ), + Semantics( + label: 'Open debug logs', + button: true, + child: IconButton( + icon: const Icon(Icons.bug_report), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const LogViewerScreen()), + ); + }, + tooltip: 'Debug Logs', + ), + ), IconButton( icon: const Icon(Icons.refresh), onPressed: _handleRefresh, @@ -205,14 +358,18 @@ class _DashboardScreenState extends State { ), ], ), - body: Consumer2( - builder: (context, authProvider, accountsProvider, _) { - // Show loading state during initialization or when loading - if (accountsProvider.isInitializing || accountsProvider.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } + body: Column( + children: [ + const ConnectivityBanner(), + Expanded( + child: Consumer2( + builder: (context, authProvider, accountsProvider, _) { + // Show loading state during initialization or when loading + if (accountsProvider.isInitializing || accountsProvider.isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } // Show error state if (accountsProvider.errorMessage != null && @@ -418,7 +575,10 @@ class _DashboardScreenState extends State { ], ), ); - }, + }, + ), + ), + ], ), ); } diff --git a/mobile/lib/screens/log_viewer_screen.dart b/mobile/lib/screens/log_viewer_screen.dart new file mode 100644 index 000000000..cbd162793 --- /dev/null +++ b/mobile/lib/screens/log_viewer_screen.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../services/log_service.dart'; + +class LogViewerScreen extends StatefulWidget { + const LogViewerScreen({super.key}); + + @override + State createState() => _LogViewerScreenState(); +} + +class _LogViewerScreenState extends State { + String _selectedLevel = 'ALL'; + final ScrollController _scrollController = ScrollController(); + bool _autoScroll = true; + + @override + void initState() { + super.initState(); + // Set log viewer as active to enable notifications + LogService.instance.setLogViewerActive(true); + // Add a test log to confirm logging is working + LogService.instance.info('LogViewer', 'Log viewer screen opened'); + } + + @override + void dispose() { + // Set log viewer as inactive to disable notifications + LogService.instance.setLogViewerActive(false); + _scrollController.dispose(); + super.dispose(); + } + + Color _getLevelColor(String level) { + switch (level) { + case 'ERROR': + return Colors.red; + case 'WARNING': + return Colors.orange; + case 'INFO': + return Colors.blue; + case 'DEBUG': + return Colors.grey; + default: + return Colors.black; + } + } + + IconData _getLevelIcon(String level) { + switch (level) { + case 'ERROR': + return Icons.error; + case 'WARNING': + return Icons.warning; + case 'INFO': + return Icons.info; + case 'DEBUG': + return Icons.bug_report; + default: + return Icons.text_snippet; + } + } + + void _scrollToBottom() { + if (_autoScroll && _scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Debug Logs'), + actions: [ + PopupMenuButton( + initialValue: _selectedLevel, + onSelected: (value) { + setState(() { + _selectedLevel = value; + }); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'ALL', child: Text('All Levels')), + const PopupMenuItem(value: 'ERROR', child: Text('Errors Only')), + const PopupMenuItem(value: 'WARNING', child: Text('Warnings Only')), + const PopupMenuItem(value: 'INFO', child: Text('Info Only')), + const PopupMenuItem(value: 'DEBUG', child: Text('Debug Only')), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.filter_list), + const SizedBox(width: 4), + Text(_selectedLevel), + ], + ), + ), + ), + IconButton( + icon: Icon(_autoScroll ? Icons.lock_open : Icons.lock), + onPressed: () { + setState(() { + _autoScroll = !_autoScroll; + }); + }, + tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll', + ), + IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + final logs = LogService.instance.exportLogs(); + Clipboard.setData(ClipboardData(text: logs)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + tooltip: 'Copy Logs', + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Logs'), + content: const Text('Are you sure you want to clear all logs?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + LogService.instance.clear(); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Clear'), + ), + ], + ), + ); + }, + tooltip: 'Clear Logs', + ), + ], + ), + body: Consumer( + builder: (context, logService, child) { + final logs = _selectedLevel == 'ALL' + ? logService.logs + : logService.logs.where((log) => log.level == _selectedLevel).toList(); + + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + + if (logs.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.text_snippet_outlined, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No logs yet', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + final color = _getLevelColor(log.level); + + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: ListTile( + dense: true, + leading: Icon( + _getLevelIcon(log.level), + color: color, + size: 20, + ), + title: Text( + '[${log.tag}] ${log.message}', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: color, + ), + ), + subtitle: Text( + log.formattedTime, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.grey, + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index a0d5c7492..cc4743080 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -3,7 +3,9 @@ import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../models/account.dart'; import '../providers/auth_provider.dart'; -import '../services/transactions_service.dart'; +import '../providers/transactions_provider.dart'; +import '../services/log_service.dart'; +import '../services/connectivity_service.dart'; class TransactionFormScreen extends StatefulWidget { final Account account; @@ -22,7 +24,7 @@ class _TransactionFormScreenState extends State { final _amountController = TextEditingController(); final _dateController = TextEditingController(); final _nameController = TextEditingController(); - final _transactionsService = TransactionsService(); + final _log = LogService.instance; String _nature = 'expense'; bool _showMoreFields = false; @@ -87,11 +89,15 @@ class _TransactionFormScreenState extends State { _isSubmitting = true; }); + _log.info('TransactionForm', 'Starting transaction creation...'); + try { final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); if (accessToken == null) { + _log.warning('TransactionForm', 'Access token is null, session expired'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -108,7 +114,10 @@ class _TransactionFormScreenState extends State { final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text); final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate); - final result = await _transactionsService.createTransaction( + _log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)'); + + // Use TransactionsProvider for offline-first transaction creation + final success = await transactionsProvider.createTransaction( accessToken: accessToken, accountId: widget.account.id, name: _nameController.text.trim(), @@ -120,29 +129,36 @@ class _TransactionFormScreenState extends State { ); if (mounted) { - if (result['success'] == true) { + if (success) { + _log.info('TransactionForm', 'Transaction created successfully (saved locally)'); + + // Check current connectivity status to show appropriate message + final connectivityService = Provider.of(context, listen: false); + final isOnline = connectivityService.isOnline; + ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Transaction created successfully'), + SnackBar( + content: Text( + isOnline + ? 'Transaction created successfully' + : 'Transaction saved (will sync when online)' + ), backgroundColor: Colors.green, ), ); Navigator.pop(context, true); // Return true to indicate success } else { - final error = result['error'] ?? 'Failed to create transaction'; + _log.error('TransactionForm', 'Failed to create transaction'); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error), + const SnackBar( + content: Text('Failed to create transaction'), backgroundColor: Colors.red, ), ); - - if (error == 'unauthorized') { - await authProvider.logout(); - } } } } catch (e) { + _log.error('TransactionForm', 'Exception during transaction creation: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 4f62675ad..df5bced82 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/account.dart'; import '../models/transaction.dart'; +import '../models/offline_transaction.dart'; import '../providers/auth_provider.dart'; import '../providers/transactions_provider.dart'; import '../screens/transaction_form_screen.dart'; +import '../widgets/sync_status_badge.dart'; +import '../services/log_service.dart'; class TransactionsListScreen extends StatefulWidget { final Account account; @@ -77,7 +80,7 @@ class _TransactionsListScreenState extends State { }; } catch (e) { // Fallback if parsing fails - log and return neutral state - debugPrint('Failed to parse amount "$amount": $e'); + LogService.instance.error('TransactionsListScreen', 'Failed to parse amount "$amount": $e'); return { 'isPositive': true, 'displayAmount': amount, @@ -188,10 +191,64 @@ class _TransactionsListScreenState extends State { } } + Future _undoTransaction(OfflineTransaction transaction) async { + final transactionsProvider = Provider.of(context, listen: false); + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Undo Transaction'), + content: Text( + transaction.syncStatus == SyncStatus.pending + ? 'Remove this pending transaction?' + : 'Restore this transaction?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Undo'), + ), + ], + ), + ); + + if (confirmed != true) return; + + final success = await transactionsProvider.undoPendingTransaction( + localId: transaction.localId, + syncStatus: transaction.syncStatus, + ); + + if (mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + success + ? (transaction.syncStatus == SyncStatus.pending + ? 'Pending transaction removed' + : 'Transaction restored') + : 'Failed to undo transaction', + ), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + } + Future _confirmAndDeleteTransaction(Transaction transaction) async { if (transaction.id == null) return false; // Show confirmation dialog + // Capture providers before async gap + final scaffoldMessenger = ScaffoldMessenger.of(context); + final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of(context, listen: false); + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -214,9 +271,6 @@ class _TransactionsListScreenState extends State { if (confirmed != true) return false; // Perform the deletion - final scaffoldMessenger = ScaffoldMessenger.of(context); - final authProvider = Provider.of(context, listen: false); - final transactionsProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); if (accessToken == null) { @@ -314,7 +368,7 @@ class _TransactionsListScreenState extends State { ); } - final transactions = transactionsProvider.transactions; + final transactions = transactionsProvider.offlineTransactions; if (transactions.isEmpty) { return RefreshIndicator( @@ -365,6 +419,11 @@ class _TransactionsListScreenState extends State { final transaction = transactions[index]; final isSelected = transaction.id != null && _selectedTransactions.contains(transaction.id); + final isPending = transaction.syncStatus == SyncStatus.pending; + final isPendingDelete = transaction.syncStatus == SyncStatus.pendingDelete; + final isFailed = transaction.syncStatus == SyncStatus.failed; + final hasPendingStatus = isPending || isPendingDelete; + // Compute display info once to avoid duplicate parsing final displayInfo = _getAmountDisplayInfo( transaction.amount, @@ -386,17 +445,19 @@ class _TransactionsListScreenState extends State { child: const Icon(Icons.delete, color: Colors.white), ), confirmDismiss: (direction) => _confirmAndDeleteTransaction(transaction), - child: Card( - margin: const EdgeInsets.only(bottom: 12), - child: InkWell( - onTap: _isSelectionMode && transaction.id != null - ? () => _toggleTransactionSelection(transaction.id!) - : null, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ + child: Opacity( + opacity: hasPendingStatus ? 0.5 : 1.0, + child: Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: _isSelectionMode && transaction.id != null + ? () => _toggleTransactionSelection(transaction.id!) + : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ if (_isSelectionMode) Padding( padding: const EdgeInsets.only(right: 12), @@ -443,13 +504,51 @@ class _TransactionsListScreenState extends State { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( - '${displayInfo['prefix']}${displayInfo['displayAmount']}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: displayInfo['color'] as Color, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasPendingStatus || isFailed) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SyncStatusBadge( + syncStatus: transaction.syncStatus, + compact: true, + ), ), + Text( + '${displayInfo['prefix']}${displayInfo['displayAmount']}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: displayInfo['color'] as Color, + ), + ), + ], ), + if (hasPendingStatus) ...[ + const SizedBox(height: 4), + InkWell( + onTap: () => _undoTransaction(transaction), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: const Text( + 'Undo', + style: TextStyle( + color: Colors.blue, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], const SizedBox(height: 4), Text( transaction.currency, @@ -461,6 +560,7 @@ class _TransactionsListScreenState extends State { ), ], ), + ), ), ), ), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 07d94de8c..789030abe 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/auth_tokens.dart'; import '../models/user.dart'; import 'api_config.dart'; +import 'log_service.dart'; class AuthService { final FlutterSecureStorage _storage = const FlutterSecureStorage(); @@ -41,8 +41,8 @@ class AuthService { body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); - debugPrint('Login response status: ${response.statusCode}'); - debugPrint('Login response body: ${response.body}'); + LogService.instance.debug('AuthService', 'Login response status: ${response.statusCode}'); + LogService.instance.debug('AuthService', 'Login response body: ${response.body}'); final responseData = jsonDecode(response.body); @@ -76,37 +76,37 @@ class AuthService { }; } } on SocketException catch (e, stackTrace) { - debugPrint('Login SocketException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login SocketException: $e\n$stackTrace'); return { 'success': false, 'error': 'Network unavailable', }; } on TimeoutException catch (e, stackTrace) { - debugPrint('Login TimeoutException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login TimeoutException: $e\n$stackTrace'); return { 'success': false, 'error': 'Request timed out', }; } on HttpException catch (e, stackTrace) { - debugPrint('Login HttpException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login HttpException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on FormatException catch (e, stackTrace) { - debugPrint('Login FormatException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login FormatException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on TypeError catch (e, stackTrace) { - debugPrint('Login TypeError: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login TypeError: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } catch (e, stackTrace) { - debugPrint('Login unexpected error: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Login unexpected error: $e\n$stackTrace'); return { 'success': false, 'error': 'An unexpected error occurred', @@ -174,37 +174,37 @@ class AuthService { }; } } on SocketException catch (e, stackTrace) { - debugPrint('Signup SocketException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup SocketException: $e\n$stackTrace'); return { 'success': false, 'error': 'Network unavailable', }; } on TimeoutException catch (e, stackTrace) { - debugPrint('Signup TimeoutException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup TimeoutException: $e\n$stackTrace'); return { 'success': false, 'error': 'Request timed out', }; } on HttpException catch (e, stackTrace) { - debugPrint('Signup HttpException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup HttpException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on FormatException catch (e, stackTrace) { - debugPrint('Signup FormatException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup FormatException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on TypeError catch (e, stackTrace) { - debugPrint('Signup TypeError: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup TypeError: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } catch (e, stackTrace) { - debugPrint('Signup unexpected error: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'Signup unexpected error: $e\n$stackTrace'); return { 'success': false, 'error': 'An unexpected error occurred', @@ -248,37 +248,37 @@ class AuthService { }; } } on SocketException catch (e, stackTrace) { - debugPrint('RefreshToken SocketException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken SocketException: $e\n$stackTrace'); return { 'success': false, 'error': 'Network unavailable', }; } on TimeoutException catch (e, stackTrace) { - debugPrint('RefreshToken TimeoutException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken TimeoutException: $e\n$stackTrace'); return { 'success': false, 'error': 'Request timed out', }; } on HttpException catch (e, stackTrace) { - debugPrint('RefreshToken HttpException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken HttpException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on FormatException catch (e, stackTrace) { - debugPrint('RefreshToken FormatException: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken FormatException: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } on TypeError catch (e, stackTrace) { - debugPrint('RefreshToken TypeError: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken TypeError: $e\n$stackTrace'); return { 'success': false, 'error': 'Invalid response from server', }; } catch (e, stackTrace) { - debugPrint('RefreshToken unexpected error: $e\n$stackTrace'); + LogService.instance.error('AuthService', 'RefreshToken unexpected error: $e\n$stackTrace'); return { 'success': false, 'error': 'An unexpected error occurred', diff --git a/mobile/lib/services/connectivity_service.dart b/mobile/lib/services/connectivity_service.dart new file mode 100644 index 000000000..35b856c27 --- /dev/null +++ b/mobile/lib/services/connectivity_service.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'log_service.dart'; + +class ConnectivityService with ChangeNotifier { + final Connectivity _connectivity = Connectivity(); + final LogService _log = LogService.instance; + StreamSubscription>? _connectivitySubscription; + + bool _isOnline = true; + bool get isOnline => _isOnline; + bool get isOffline => !_isOnline; + + ConnectivityService() { + _log.info('ConnectivityService', 'Initializing connectivity service'); + _initConnectivity().then((_) { + _connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); + }); + } + + Future _initConnectivity() async { + try { + final result = await _connectivity.checkConnectivity(); + _log.info('ConnectivityService', 'Initial connectivity check: $result'); + _updateConnectionStatus(result); + } catch (e) { + // If we can't determine connectivity, assume we're offline + _log.error('ConnectivityService', 'Failed to check connectivity: $e'); + _isOnline = false; + notifyListeners(); + } + } + + void _updateConnectionStatus(List results) { + final wasOnline = _isOnline; + + // Check if any result indicates connectivity + _isOnline = results.any((result) => + result == ConnectivityResult.mobile || + result == ConnectivityResult.wifi || + result == ConnectivityResult.ethernet || + result == ConnectivityResult.vpn || + result == ConnectivityResult.bluetooth); + + _log.info('ConnectivityService', 'Connectivity changed: $results -> ${_isOnline ? "ONLINE" : "OFFLINE"}'); + + // Only notify if the status changed + if (wasOnline != _isOnline) { + _log.info('ConnectivityService', 'Connection status changed from ${wasOnline ? "ONLINE" : "OFFLINE"} to ${_isOnline ? "ONLINE" : "OFFLINE"}'); + notifyListeners(); + } + } + + Future checkConnectivity() async { + try { + final result = await _connectivity.checkConnectivity(); + _updateConnectionStatus(result); + return _isOnline; + } catch (e) { + return false; + } + } + + @override + void dispose() { + _connectivitySubscription?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart new file mode 100644 index 000000000..d483a034b --- /dev/null +++ b/mobile/lib/services/database_helper.dart @@ -0,0 +1,309 @@ +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; + final LogService _log = LogService.instance; + + DatabaseHelper._init(); + + Future get database async { + 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 _initDB(String filePath) async { + try { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + + return await openDatabase( + path, + version: 1, + onCreate: _createDB, + ); + } 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 _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, + 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; + } + } + + // Transaction CRUD operations + Future insertTransaction(Map transaction) async { + 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>> getTransactions({String? accountId}) async { + 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?> getTransactionByLocalId(String localId) async { + final db = await database; + final results = await db.query( + 'transactions', + where: 'local_id = ?', + whereArgs: [localId], + limit: 1, + ); + + return results.isNotEmpty ? results.first : null; + } + + Future?> getTransactionByServerId(String serverId) async { + final db = await database; + final results = await db.query( + 'transactions', + where: 'server_id = ?', + whereArgs: [serverId], + limit: 1, + ); + + return results.isNotEmpty ? results.first : null; + } + + Future>> getPendingTransactions() async { + final db = await database; + return await db.query( + 'transactions', + where: 'sync_status = ?', + whereArgs: ['pending'], + orderBy: 'created_at ASC', + ); + } + + Future>> getPendingDeletes() async { + final db = await database; + return await db.query( + 'transactions', + where: 'sync_status = ?', + whereArgs: ['pending_delete'], + orderBy: 'updated_at ASC', + ); + } + + Future updateTransaction(String localId, Map transaction) async { + final db = await database; + return await db.update( + 'transactions', + transaction, + where: 'local_id = ?', + whereArgs: [localId], + ); + } + + Future deleteTransaction(String localId) async { + final db = await database; + return await db.delete( + 'transactions', + where: 'local_id = ?', + whereArgs: [localId], + ); + } + + Future deleteTransactionByServerId(String serverId) async { + final db = await database; + return await db.delete( + 'transactions', + where: 'server_id = ?', + whereArgs: [serverId], + ); + } + + Future clearTransactions() async { + final db = await database; + await db.delete('transactions'); + } + + Future clearSyncedTransactions() async { + 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 insertAccount(Map account) async { + final db = await database; + await db.insert( + 'accounts', + account, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future insertAccounts(List> accounts) async { + 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>> getAccounts() async { + final db = await database; + return await db.query('accounts', orderBy: 'name ASC'); + } + + Future?> getAccountById(String id) async { + final db = await database; + final results = await db.query( + 'accounts', + where: 'id = ?', + whereArgs: [id], + limit: 1, + ); + + return results.isNotEmpty ? results.first : null; + } + + Future clearAccounts() async { + final db = await database; + await db.delete('accounts'); + } + + // Utility methods + Future clearAllData() async { + final db = await database; + await db.delete('transactions'); + await db.delete('accounts'); + } + + Future close() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } +} diff --git a/mobile/lib/services/log_service.dart b/mobile/lib/services/log_service.dart new file mode 100644 index 000000000..83c0a389d --- /dev/null +++ b/mobile/lib/services/log_service.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; + +class LogEntry { + final DateTime timestamp; + final String level; // INFO, DEBUG, ERROR, WARNING + final String tag; + final String message; + + LogEntry({ + required this.timestamp, + required this.level, + required this.tag, + required this.message, + }); + + String get formattedTime { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}.' + '${timestamp.millisecond.toString().padLeft(3, '0')}'; + } +} + +class LogService with ChangeNotifier { + static final LogService instance = LogService._internal(); + factory LogService() => instance; + LogService._internal(); + + final List _logs = []; + final int _maxLogs = 500; // Reduced from 1000 to save memory + bool _isLogViewerActive = false; + + List get logs => List.unmodifiable(_logs); + + /// Call this when log viewer screen becomes active + void setLogViewerActive(bool active) { + _isLogViewerActive = active; + } + + void log(String tag, String message, {String level = 'INFO'}) { + final entry = LogEntry( + timestamp: DateTime.now(), + level: level, + tag: tag, + message: message, + ); + + _logs.add(entry); + + // Keep only the last _maxLogs entries + if (_logs.length > _maxLogs) { + _logs.removeAt(0); + } + + // Also print to console for development + debugPrint('[$level][$tag] $message'); + + // Only notify listeners if log viewer is active to avoid unnecessary rebuilds + if (_isLogViewerActive) { + notifyListeners(); + } + } + + void debug(String tag, String message) => log(tag, message, level: 'DEBUG'); + void info(String tag, String message) => log(tag, message, level: 'INFO'); + void warning(String tag, String message) => log(tag, message, level: 'WARNING'); + void error(String tag, String message) => log(tag, message, level: 'ERROR'); + + void clear() { + _logs.clear(); + notifyListeners(); + } + + String exportLogs() { + final buffer = StringBuffer(); + for (final log in _logs) { + buffer.writeln('${log.formattedTime} [${log.level}][${log.tag}] ${log.message}'); + } + return buffer.toString(); + } +} diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart new file mode 100644 index 000000000..f6df78743 --- /dev/null +++ b/mobile/lib/services/offline_storage_service.dart @@ -0,0 +1,296 @@ +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 saveTransaction({ + required String accountId, + required String name, + required String date, + required String amount, + required String currency, + required String nature, + String? notes, + 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, + 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> 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 getTransactionByLocalId(String localId) async { + final map = await _dbHelper.getTransactionByLocalId(localId); + return map != null ? OfflineTransaction.fromDatabaseMap(map) : null; + } + + Future getTransactionByServerId(String serverId) async { + final map = await _dbHelper.getTransactionByServerId(serverId); + return map != null ? OfflineTransaction.fromDatabaseMap(map) : null; + } + + Future> getPendingTransactions() async { + final transactionMaps = await _dbHelper.getPendingTransactions(); + return transactionMaps + .map((map) => OfflineTransaction.fromDatabaseMap(map)) + .toList(); + } + + Future> getPendingDeletes() async { + final transactionMaps = await _dbHelper.getPendingDeletes(); + return transactionMaps + .map((map) => OfflineTransaction.fromDatabaseMap(map)) + .toList(); + } + + Future 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 deleteTransaction(String localId) async { + await _dbHelper.deleteTransaction(localId); + } + + Future deleteTransactionByServerId(String serverId) async { + await _dbHelper.deleteTransactionByServerId(serverId); + } + + /// Mark a transaction for pending deletion (offline delete) + Future 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 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 syncTransactionsFromServer(List serverTransactions) async { + _log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); + + // 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; + for (final transaction in serverTransactions) { + if (transaction.id != null) { + await upsertTransactionFromServer(transaction); + upsertedCount++; + } + } + + _log.info('OfflineStorage', 'Upserted $upsertedCount transactions from server'); + } + + Future 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.debug('OfflineStorage', 'Upserting transaction ${transaction.id}: accountId="${transaction.accountId}" -> effective="$effectiveAccountId"'); + + // Check if we already have this transaction + final existing = await getTransactionByServerId(transaction.id!); + + if (existing != null) { + _log.debug('OfflineStorage', 'Updating existing transaction (localId: ${existing.localId}, was ${existing.syncStatus})'); + // Update existing transaction, preserving its accountId if effectiveAccountId is empty + final finalAccountId = effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId; + 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, + syncStatus: SyncStatus.synced, + ); + await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap()); + _log.debug('OfflineStorage', 'Transaction updated successfully with accountId="$finalAccountId"'); + } else { + _log.debug('OfflineStorage', 'Inserting new transaction with accountId="$effectiveAccountId"'); + // Insert new transaction + 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, + syncStatus: SyncStatus.synced, + ); + await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap()); + _log.debug('OfflineStorage', 'Transaction inserted successfully'); + } + } + + Future clearTransactions() async { + await _dbHelper.clearTransactions(); + } + + // Account operations (for caching) + Future 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 saveAccounts(List 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> getAccounts() async { + final accountMaps = await _dbHelper.getAccounts(); + return accountMaps.map((map) => Account.fromJson(map)).toList(); + } + + Future getAccountById(String id) async { + final map = await _dbHelper.getAccountById(id); + return map != null ? Account.fromJson(map) : null; + } + + Future clearAccounts() async { + await _dbHelper.clearAccounts(); + } + + // Utility methods + Future clearAllData() async { + await _dbHelper.clearAllData(); + } +} diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart new file mode 100644 index 000000000..e7c8f328d --- /dev/null +++ b/mobile/lib/services/sync_service.dart @@ -0,0 +1,394 @@ +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 _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 _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, + ); + + 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 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 syncFromServer({ + required String accessToken, + String? accountId, + }) async { + try { + _log.debug('SyncService', 'Fetching transactions from server (accountId: $accountId)'); + final result = await _transactionsService.getTransactions( + accessToken: accessToken, + accountId: accountId, + ); + + if (result['success'] == true) { + final transactions = (result['transactions'] as List?) + ?.cast() ?? []; + + _log.info('SyncService', 'Received ${transactions.length} transactions from server'); + + // Update local cache with server data + if (accountId == null) { + _log.debug('SyncService', 'Full sync - clearing and replacing all transactions'); + // Full sync - replace all transactions + await _offlineStorage.syncTransactionsFromServer(transactions); + } else { + _log.debug('SyncService', 'Partial sync - upserting ${transactions.length} transactions for account $accountId'); + // Partial sync - upsert transactions + for (final transaction in transactions) { + _log.debug('SyncService', 'Upserting transaction ${transaction.id} (accountId from server: "${transaction.accountId}", provided: "$accountId")'); + await _offlineStorage.upsertTransactionFromServer( + transaction, + accountId: accountId, + ); + } + } + + _lastSyncTime = DateTime.now(); + notifyListeners(); + + return SyncResult( + success: true, + syncedCount: transactions.length, + ); + } else { + _log.error('SyncService', 'Server returned error: ${result['error']}'); + return SyncResult( + success: false, + error: result['error'] as String? ?? 'Failed to sync from server', + ); + } + } 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 syncAccounts(String accessToken) async { + try { + final result = await _accountsService.getAccounts(accessToken: accessToken); + + if (result['success'] == true) { + final accountsList = result['accounts'] as List? ?? []; + + // 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 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 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, + }); +} diff --git a/mobile/lib/widgets/connectivity_banner.dart b/mobile/lib/widgets/connectivity_banner.dart new file mode 100644 index 000000000..ece413fd8 --- /dev/null +++ b/mobile/lib/widgets/connectivity_banner.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../services/connectivity_service.dart'; +import '../providers/transactions_provider.dart'; +import '../providers/auth_provider.dart'; + +class ConnectivityBanner extends StatefulWidget { + const ConnectivityBanner({super.key}); + + @override + State createState() => _ConnectivityBannerState(); +} + +class _ConnectivityBannerState extends State { + bool _isSyncing = false; + + Future _handleSync(BuildContext context, String? accessToken, TransactionsProvider transactionsProvider) async { + if (accessToken == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please sign in to sync transactions'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + setState(() { + _isSyncing = true; + }); + + try { + await transactionsProvider.syncTransactions(accessToken: accessToken); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Transactions synced successfully'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to sync transactions. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } finally { + if (mounted) { + setState(() { + _isSyncing = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, connectivityService, transactionsProvider, _) { + final isOffline = connectivityService.isOffline; + final hasPending = transactionsProvider.hasPendingTransactions; + final pendingCount = transactionsProvider.pendingCount; + + if (!isOffline && !hasPending) { + return const SizedBox.shrink(); + } + + return Material( + color: isOffline ? Colors.orange.shade100 : Colors.blue.shade100, + elevation: 2, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + isOffline ? Icons.cloud_off : Icons.sync, + color: isOffline ? Colors.orange.shade900 : Colors.blue.shade900, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isOffline + ? 'You are offline. Changes will sync when online.' + : '$pendingCount transaction${pendingCount == 1 ? '' : 's'} pending sync', + style: TextStyle( + color: isOffline ? Colors.orange.shade900 : Colors.blue.shade900, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (!isOffline && hasPending) + Consumer( + builder: (context, authProvider, _) { + return TextButton( + onPressed: _isSyncing + ? null + : () => _handleSync( + context, + authProvider.tokens?.accessToken, + transactionsProvider, + ), + style: TextButton.styleFrom( + foregroundColor: Colors.blue.shade900, + ), + child: _isSyncing + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.blue.shade900), + ), + ) + : const Text('Sync Now'), + ); + }, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/sync_status_badge.dart b/mobile/lib/widgets/sync_status_badge.dart new file mode 100644 index 000000000..658fbf7c4 --- /dev/null +++ b/mobile/lib/widgets/sync_status_badge.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../models/offline_transaction.dart'; + +class SyncStatusBadge extends StatelessWidget { + final SyncStatus syncStatus; + final bool compact; + + const SyncStatusBadge({ + super.key, + required this.syncStatus, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + if (syncStatus == SyncStatus.synced) { + return const SizedBox.shrink(); + } + + final Color color; + final IconData icon; + final String text; + final String semanticsLabel; + + switch (syncStatus) { + case SyncStatus.pending: + color = Colors.orange; + icon = Icons.sync; + text = 'Pending'; + semanticsLabel = 'Transaction pending sync'; + break; + case SyncStatus.pendingDelete: + color = Colors.red.shade300; + icon = Icons.delete_outline; + text = 'Deleting'; + semanticsLabel = 'Transaction pending deletion'; + break; + case SyncStatus.failed: + color = Colors.red; + icon = Icons.error_outline; + text = 'Failed'; + semanticsLabel = 'Sync failed'; + break; + case SyncStatus.synced: + return const SizedBox.shrink(); + } + + if (compact) { + return Semantics( + label: semanticsLabel, + child: Icon( + icon, + size: 16, + color: color, + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Semantics( + label: semanticsLabel, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: color, + ), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fe34c87a1..2c8876e50 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,6 +16,10 @@ dependencies: shared_preferences: ^2.2.2 flutter_secure_storage: ^10.0.0 intl: ^0.18.1 + sqflite: ^2.4.2 + path: ^1.9.1 + connectivity_plus: ^7.0.0 + uuid: ^4.5.2 dev_dependencies: flutter_test: