Files
fin65-mobile/lib/services/sync_service.dart
T

272 lines
7.8 KiB
Dart

import '../config/app_config.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/pending_operation.dart';
import '../models/transaction_item.dart';
import 'api_service.dart';
import 'offline_store.dart';
class SyncSnapshot {
final DashboardData? dashboard;
final List<Account> accounts;
final List<Category> categories;
final List<TransactionItem> transactions;
final int pendingCount;
final DateTime? lastSyncAt;
final bool isOnline;
final String? message;
SyncSnapshot({
required this.dashboard,
required this.accounts,
required this.categories,
required this.transactions,
required this.pendingCount,
required this.lastSyncAt,
required this.isOnline,
this.message,
});
}
class SyncService {
SyncService({
ApiService? apiService,
OfflineStore? offlineStore,
}) : store = offlineStore ?? OfflineStore.instance,
apiService = apiService ?? ApiService(store: offlineStore ?? OfflineStore.instance);
final OfflineStore store;
final ApiService apiService;
Future<List<PendingOperation>> loadPendingOperations() {
return store.loadPendingOperations();
}
Future<String> getApiUrl() {
return apiService.getBaseUrl();
}
Future<bool> testBackend() async {
try {
return await apiService.healthCheck();
} catch (_) {
return false;
}
}
Future<SyncSnapshot> loadLocalSnapshot({bool isOnline = false, String? message}) async {
final dashboard = await store.loadDashboard();
final accounts = await store.loadAccounts();
final categories = await store.loadCategories();
final transactions = await store.loadTransactions(limit: AppConfig.defaultTransactionLimit);
final pending = await store.loadPendingOperations();
final lastSyncAt = await store.getLastSyncAt();
return _applyPendingEstimates(
dashboard: dashboard,
accounts: accounts,
categories: categories,
transactions: transactions,
pending: pending,
lastSyncAt: lastSyncAt,
isOnline: isOnline,
message: message,
);
}
Future<SyncSnapshot> synchronize() async {
try {
await apiService.healthCheck();
await _syncPendingOperations();
final accounts = await apiService.getAccounts();
final categories = await apiService.getCategories();
final dashboard = await apiService.getDashboard();
final transactions = await apiService.getTransactions(limit: AppConfig.defaultTransactionLimit);
await store.saveServerSnapshot(
dashboard: dashboard,
accounts: accounts,
categories: categories,
transactions: transactions,
);
return loadLocalSnapshot(isOnline: true, message: null);
} catch (e) {
return loadLocalSnapshot(
isOnline: false,
message: 'Backend indisponible. Données locales affichées.',
);
}
}
Future<bool> createTransactionSmart({
required String label,
required double amount,
required String type,
required int accountId,
int? targetAccountId,
int? categoryId,
String? note,
}) async {
final payload = <String, dynamic>{
'date': DateTime.now().toIso8601String(),
'label': label,
'amount': amount,
'type': type,
'account_id': accountId,
'target_account_id': type == 'transfer' ? targetAccountId : null,
'category_id': type == 'transfer' ? null : categoryId,
'created_from': 'android',
'note': note,
};
try {
await apiService.healthCheck();
await apiService.createTransactionFromPayload(payload);
await synchronize();
return true;
} catch (_) {
// Offline or backend unreachable: the operation is persisted locally in
// SQLite. The UI can then immediately reload an estimated snapshot.
await store.enqueueCreateTransaction(payload);
return false;
}
}
Future<void> _syncPendingOperations() async {
final pending = await store.loadPendingOperations();
for (final operation in pending) {
try {
if (operation.operationType == 'create_transaction') {
await apiService.createTransactionFromPayload(operation.payload);
await store.deletePendingOperation(operation.id);
}
} catch (e) {
await store.markPendingError(operation.id, e.toString());
rethrow;
}
}
}
SyncSnapshot _applyPendingEstimates({
required DashboardData? dashboard,
required List<Account> accounts,
required List<Category> categories,
required List<TransactionItem> transactions,
required List<PendingOperation> pending,
required DateTime? lastSyncAt,
required bool isOnline,
String? message,
}) {
final accountMap = <int, Account>{
for (final account in accounts) account.id: account,
};
var adjustedDashboard = dashboard;
final pendingTransactions = <TransactionItem>[];
for (final operation in pending) {
if (operation.operationType != 'create_transaction') continue;
final item = TransactionItem.fromPending(
localOperationId: operation.id,
payload: operation.payload,
);
pendingTransactions.add(item);
final amount = item.amount;
final source = accountMap[item.accountId];
if (item.type == 'expense') {
if (source != null) {
accountMap[item.accountId] = source.copyWith(
appBalance: source.appBalance - amount,
);
}
adjustedDashboard = _adjustDashboard(
adjustedDashboard,
totalDelta: -amount,
availableDelta: -amount,
incomeDelta: 0,
expenseDelta: amount,
monthlyBalanceDelta: -amount,
);
} else if (item.type == 'income') {
if (source != null) {
accountMap[item.accountId] = source.copyWith(
appBalance: source.appBalance + amount,
);
}
adjustedDashboard = _adjustDashboard(
adjustedDashboard,
totalDelta: amount,
availableDelta: amount,
incomeDelta: amount,
expenseDelta: 0,
monthlyBalanceDelta: amount,
);
} else if (item.type == 'transfer') {
if (source != null) {
accountMap[item.accountId] = source.copyWith(
appBalance: source.appBalance - amount,
);
}
final targetId = item.targetAccountId;
if (targetId != null) {
final target = accountMap[targetId];
if (target != null) {
accountMap[targetId] = target.copyWith(
appBalance: target.appBalance + amount,
);
}
}
}
}
final adjustedAccounts = accountMap.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final allTransactions = [
...pendingTransactions.reversed,
...transactions,
];
return SyncSnapshot(
dashboard: adjustedDashboard,
accounts: adjustedAccounts,
categories: categories,
transactions: allTransactions,
pendingCount: pending.length,
lastSyncAt: lastSyncAt,
isOnline: isOnline,
message: message,
);
}
DashboardData? _adjustDashboard(
DashboardData? dashboard, {
required double totalDelta,
required double availableDelta,
required double incomeDelta,
required double expenseDelta,
required double monthlyBalanceDelta,
}) {
if (dashboard == null) return null;
return dashboard.copyWith(
totalBalance: dashboard.totalBalance + totalDelta,
availableAfterReserves: dashboard.availableAfterReserves + availableDelta,
monthlyIncome: dashboard.monthlyIncome + incomeDelta,
monthlyExpenses: dashboard.monthlyExpenses + expenseDelta,
monthlyBalance: dashboard.monthlyBalance + monthlyBalanceDelta,
);
}
}