mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Backend fixes: - Fix duplicate AssistantResponseJob triggering causing duplicate AI responses - UserMessage model already handles job triggering via after_create_commit callback - Remove redundant job enqueue in chats_controller and messages_controller Mobile app features: - Implement complete AI chat interface and conversation management - Add Chat, Message, and ToolCall data models - Add ChatProvider for state management with polling mechanism - Add ChatService to handle all chat-related API requests - Add chat list screen (ChatListScreen) - Add conversation detail screen (ChatConversationScreen) - Refactor navigation structure with bottom navigation bar (MainNavigationScreen) - Add settings screen (SettingsScreen) - Optimize TransactionsProvider to support account filtering Technical details: - Implement message polling mechanism for real-time AI responses - Support chat creation, deletion, retry and other operations - Integrate Material Design 3 design language - Improve user experience and error handling Co-authored-by: dwvwdv <dwvwdv@protonmail.com>
412 lines
14 KiB
Dart
412 lines
14 KiB
Dart
import 'dart:collection';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../models/transaction.dart';
|
|
import '../models/offline_transaction.dart';
|
|
import '../services/transactions_service.dart';
|
|
import '../services/offline_storage_service.dart';
|
|
import '../services/sync_service.dart';
|
|
import '../services/connectivity_service.dart';
|
|
import '../services/log_service.dart';
|
|
|
|
class TransactionsProvider with ChangeNotifier {
|
|
final TransactionsService _transactionsService = TransactionsService();
|
|
final OfflineStorageService _offlineStorage = OfflineStorageService();
|
|
final SyncService _syncService = SyncService();
|
|
final LogService _log = LogService.instance;
|
|
|
|
List<OfflineTransaction> _transactions = [];
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
ConnectivityService? _connectivityService;
|
|
String? _lastAccessToken;
|
|
String? _currentAccountId; // Track current account for filtering
|
|
bool _isAutoSyncing = false;
|
|
bool _isListenerAttached = false;
|
|
bool _isDisposed = false;
|
|
|
|
List<Transaction> get transactions =>
|
|
UnmodifiableListView(_transactions.map((t) => t.toTransaction()));
|
|
|
|
List<OfflineTransaction> get offlineTransactions =>
|
|
UnmodifiableListView(_transactions);
|
|
|
|
bool get isLoading => _isLoading;
|
|
String? get error => _error;
|
|
bool get hasPendingTransactions =>
|
|
_transactions.any((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete);
|
|
int get pendingCount =>
|
|
_transactions.where((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete).length;
|
|
|
|
SyncService get syncService => _syncService;
|
|
|
|
void setConnectivityService(ConnectivityService service) {
|
|
_connectivityService = service;
|
|
if (!_isListenerAttached) {
|
|
_connectivityService?.addListener(_onConnectivityChanged);
|
|
_isListenerAttached = true;
|
|
}
|
|
}
|
|
|
|
void _onConnectivityChanged() {
|
|
if (_isDisposed) return;
|
|
|
|
// Auto-sync when connectivity is restored
|
|
if (_connectivityService?.isOnline == true &&
|
|
hasPendingTransactions &&
|
|
_lastAccessToken != null &&
|
|
!_isAutoSyncing) {
|
|
_log.info('TransactionsProvider', 'Connectivity restored, auto-syncing $pendingCount pending transactions');
|
|
_isAutoSyncing = true;
|
|
|
|
// Fire and forget - we don't await to avoid blocking connectivity listener
|
|
// Use callbacks to handle completion and errors asynchronously
|
|
syncTransactions(accessToken: _lastAccessToken!)
|
|
.then((_) {
|
|
if (!_isDisposed) {
|
|
_log.info('TransactionsProvider', 'Auto-sync completed successfully');
|
|
}
|
|
})
|
|
.catchError((e) {
|
|
if (!_isDisposed) {
|
|
_log.error('TransactionsProvider', 'Auto-sync failed: $e');
|
|
}
|
|
})
|
|
.whenComplete(() {
|
|
if (!_isDisposed) {
|
|
_isAutoSyncing = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Helper to check if object is still valid
|
|
bool get mounted => !_isDisposed;
|
|
|
|
/// Fetch transactions (offline-first approach)
|
|
Future<void> fetchTransactions({
|
|
required String accessToken,
|
|
String? accountId,
|
|
bool forceSync = false,
|
|
}) async {
|
|
_lastAccessToken = accessToken; // Store for auto-sync
|
|
_currentAccountId = accountId; // Track current account
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Always load from local storage first
|
|
final localTransactions = await _offlineStorage.getTransactions(
|
|
accountId: accountId,
|
|
);
|
|
|
|
_log.debug('TransactionsProvider', 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)');
|
|
|
|
_transactions = localTransactions;
|
|
notifyListeners();
|
|
|
|
// If online and force sync, or if local storage is empty, sync from server
|
|
final isOnline = _connectivityService?.isOnline ?? true;
|
|
_log.debug('TransactionsProvider', 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}');
|
|
|
|
if (isOnline && (forceSync || localTransactions.isEmpty)) {
|
|
_log.debug('TransactionsProvider', 'Syncing from server for accountId: $accountId');
|
|
final result = await _syncService.syncFromServer(
|
|
accessToken: accessToken,
|
|
accountId: accountId,
|
|
);
|
|
|
|
if (result.success) {
|
|
_log.info('TransactionsProvider', 'Sync successful, synced ${result.syncedCount} transactions');
|
|
// Reload from local storage after sync
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: accountId,
|
|
);
|
|
_log.debug('TransactionsProvider', 'After sync, loaded ${updatedTransactions.length} transactions from local storage');
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
} else {
|
|
_log.error('TransactionsProvider', 'Sync failed: ${result.error}');
|
|
_error = result.error;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Error in fetchTransactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Create a new transaction (offline-first)
|
|
Future<bool> createTransaction({
|
|
required String accessToken,
|
|
required String accountId,
|
|
required String name,
|
|
required String date,
|
|
required String amount,
|
|
required String currency,
|
|
required String nature,
|
|
String? notes,
|
|
}) 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<bool> deleteTransaction({
|
|
required String accessToken,
|
|
required String transactionId,
|
|
}) async {
|
|
try {
|
|
final isOnline = _connectivityService?.isOnline ?? false;
|
|
|
|
if (isOnline) {
|
|
// Try to delete on server
|
|
final result = await _transactionsService.deleteTransaction(
|
|
accessToken: accessToken,
|
|
transactionId: transactionId,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
// Delete from local storage
|
|
await _offlineStorage.deleteTransactionByServerId(transactionId);
|
|
_transactions.removeWhere((t) => t.id == transactionId);
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = result['error'] as String? ?? 'Failed to delete transaction';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} else {
|
|
// Offline - mark for deletion and sync later
|
|
_log.info('TransactionsProvider', 'Offline: Marking transaction for deletion');
|
|
await _offlineStorage.markTransactionForDeletion(transactionId);
|
|
|
|
// Reload from storage to update UI with pending delete status
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to delete transaction: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Delete multiple transactions
|
|
Future<bool> deleteMultipleTransactions({
|
|
required String accessToken,
|
|
required List<String> transactionIds,
|
|
}) async {
|
|
try {
|
|
final isOnline = _connectivityService?.isOnline ?? false;
|
|
|
|
if (isOnline) {
|
|
final result = await _transactionsService.deleteMultipleTransactions(
|
|
accessToken: accessToken,
|
|
transactionIds: transactionIds,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
// Delete from local storage
|
|
for (final id in transactionIds) {
|
|
await _offlineStorage.deleteTransactionByServerId(id);
|
|
}
|
|
_transactions.removeWhere((t) => transactionIds.contains(t.id));
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = result['error'] as String? ?? 'Failed to delete transactions';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} else {
|
|
// Offline - mark all for deletion and sync later
|
|
_log.info('TransactionsProvider', 'Offline: Marking ${transactionIds.length} transactions for deletion');
|
|
for (final id in transactionIds) {
|
|
await _offlineStorage.markTransactionForDeletion(id);
|
|
}
|
|
|
|
// Reload from storage to update UI with pending delete status
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
notifyListeners();
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to delete multiple transactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Undo a pending transaction (either pending create or pending delete)
|
|
Future<bool> undoPendingTransaction({
|
|
required String localId,
|
|
required SyncStatus syncStatus,
|
|
}) async {
|
|
_log.info('TransactionsProvider', 'Undoing transaction $localId with status $syncStatus');
|
|
|
|
try {
|
|
final success = await _offlineStorage.undoPendingTransaction(localId, syncStatus);
|
|
|
|
if (success) {
|
|
// Reload from storage to update UI
|
|
final updatedTransactions = await _offlineStorage.getTransactions(
|
|
accountId: _currentAccountId,
|
|
);
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
notifyListeners();
|
|
return true;
|
|
} else {
|
|
_error = 'Failed to undo transaction';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to undo transaction: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Manually trigger sync
|
|
Future<void> syncTransactions({
|
|
required String accessToken,
|
|
}) async {
|
|
if (_connectivityService?.isOffline == true) {
|
|
_error = 'Cannot sync while offline';
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _syncService.performFullSync(accessToken);
|
|
|
|
if (result.success) {
|
|
// Reload from local storage
|
|
final updatedTransactions = await _offlineStorage.getTransactions();
|
|
_transactions = updatedTransactions;
|
|
_error = null;
|
|
} else {
|
|
_error = result.error;
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionsProvider', 'Failed to sync transactions: $e');
|
|
_error = 'Something went wrong. Please try again.';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void clearTransactions() {
|
|
_transactions = [];
|
|
_error = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearError() {
|
|
_error = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
if (_isListenerAttached && _connectivityService != null) {
|
|
_connectivityService!.removeListener(_onConnectivityChanged);
|
|
_isListenerAttached = false;
|
|
}
|
|
_connectivityService = null;
|
|
super.dispose();
|
|
}
|
|
}
|