Initial upload: fin65-mobile

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:59:55 +01:00
commit 46558a5ec8
242 changed files with 16334 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
class AppConfig {
static const String defaultApiBaseUrl = 'http://10.0.2.2:8000';
static const int defaultTransactionLimit = 50;
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'screens/home_screen.dart';
import 'services/offline_store.dart';
import 'services/sync_service.dart';
import 'services/api_service.dart';
import 'services/security_service.dart';
import 'screens/app_lock_gate.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeDateFormatting('fr_FR', null);
ErrorWidget.builder = (FlutterErrorDetails details) {
return Material(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Text(
'Erreur FIN65 mobile :\n\n${details.exceptionAsString()}',
style: const TextStyle(fontSize: 14),
),
),
),
),
);
};
runApp(const Fin65MobileApp());
}
class Fin65MobileApp extends StatelessWidget {
const Fin65MobileApp({super.key});
@override
Widget build(BuildContext context) {
final store = OfflineStore.instance;
final apiService = ApiService(store: store);
final syncService = SyncService(
offlineStore: store,
apiService: apiService,
);
return MaterialApp(
title: 'FIN65',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF1F6FEB),
useMaterial3: true,
cardTheme: const CardThemeData(
margin: EdgeInsets.symmetric(vertical: 4),
),
),
home: AppLockGate(
securityService: SecurityService(store: store),
child: HomeScreen(syncService: syncService),
),
);
}
}
+90
View File
@@ -0,0 +1,90 @@
class Account {
final int id;
final String name;
final String type;
final String currency;
final double initialBalance;
final double appBalance;
final bool isActive;
Account({
required this.id,
required this.name,
required this.type,
required this.currency,
required this.initialBalance,
required this.appBalance,
required this.isActive,
});
factory Account.fromJson(Map<String, dynamic> json) {
return Account(
id: json['id'] as int,
name: json['name'] as String,
type: json['type'] as String,
currency: json['currency'] as String? ?? 'MAD',
initialBalance: (json['initial_balance'] as num? ?? 0).toDouble(),
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
isActive: json['is_active'] is int
? (json['is_active'] as int) == 1
: json['is_active'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'type': type,
'currency': currency,
'initial_balance': initialBalance,
'app_balance': appBalance,
'is_active': isActive,
};
}
Map<String, dynamic> toDbMap() {
return {
'id': id,
'name': name,
'type': type,
'currency': currency,
'initial_balance': initialBalance,
'app_balance': appBalance,
'is_active': isActive ? 1 : 0,
'updated_at': DateTime.now().toIso8601String(),
};
}
factory Account.fromDbMap(Map<String, dynamic> map) {
return Account(
id: map['id'] as int,
name: map['name'] as String,
type: map['type'] as String,
currency: map['currency'] as String? ?? 'MAD',
initialBalance: (map['initial_balance'] as num? ?? 0).toDouble(),
appBalance: (map['app_balance'] as num? ?? 0).toDouble(),
isActive: (map['is_active'] as int? ?? 1) == 1,
);
}
Account copyWith({
int? id,
String? name,
String? type,
String? currency,
double? initialBalance,
double? appBalance,
bool? isActive,
}) {
return Account(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
currency: currency ?? this.currency,
initialBalance: initialBalance ?? this.initialBalance,
appBalance: appBalance ?? this.appBalance,
isActive: isActive ?? this.isActive,
);
}
}
+50
View File
@@ -0,0 +1,50 @@
class Category {
final int id;
final String name;
final String type;
final int? parentId;
Category({
required this.id,
required this.name,
required this.type,
this.parentId,
});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'] as int,
name: json['name'] as String,
type: json['type'] as String,
parentId: json['parent_id'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'type': type,
'parent_id': parentId,
};
}
Map<String, dynamic> toDbMap() {
return {
'id': id,
'name': name,
'type': type,
'parent_id': parentId,
'updated_at': DateTime.now().toIso8601String(),
};
}
factory Category.fromDbMap(Map<String, dynamic> map) {
return Category(
id: map['id'] as int,
name: map['name'] as String,
type: map['type'] as String,
parentId: map['parent_id'] as int?,
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import 'account.dart';
class ReconciliationStatus {
final int accountId;
final String accountName;
final double appBalance;
final bool checkedToday;
final double? realBalance;
final double? difference;
ReconciliationStatus({
required this.accountId,
required this.accountName,
required this.appBalance,
required this.checkedToday,
this.realBalance,
this.difference,
});
factory ReconciliationStatus.fromJson(Map<String, dynamic> json) {
return ReconciliationStatus(
accountId: json['account_id'] as int,
accountName: json['account_name'] as String,
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
checkedToday: json['checked_today'] is int
? (json['checked_today'] as int) == 1
: json['checked_today'] as bool? ?? false,
realBalance: (json['real_balance'] as num?)?.toDouble(),
difference: (json['difference'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'account_id': accountId,
'account_name': accountName,
'app_balance': appBalance,
'checked_today': checkedToday,
'real_balance': realBalance,
'difference': difference,
};
}
ReconciliationStatus copyWith({
double? appBalance,
bool? checkedToday,
double? realBalance,
double? difference,
}) {
return ReconciliationStatus(
accountId: accountId,
accountName: accountName,
appBalance: appBalance ?? this.appBalance,
checkedToday: checkedToday ?? this.checkedToday,
realBalance: realBalance ?? this.realBalance,
difference: difference ?? this.difference,
);
}
}
class DashboardData {
final String month;
final double totalBalance;
final double availableAfterReserves;
final double monthlyIncome;
final double monthlyExpenses;
final double monthlyBalance;
final double provisionsCurrentTotal;
final double provisionsTargetTotal;
final double monthlyChargesRemainingTotal;
final double monthlyTransferRemainingTotal;
final double monthlyProvisionOperationRemainingTotal;
// Nouveaux champs backend : échéancier / opérations prévues ponctuelles.
final double scheduledExpensesRemainingTotal;
final double scheduledIncomeRemainingTotal;
final double scheduledTransferRemainingTotal;
final double scheduledProvisionRemainingTotal;
final double scheduledNetRemainingTotal;
final double projectedMonthBalance;
final List<Account> accounts;
final List<ReconciliationStatus> reconciliationsToday;
DashboardData({
required this.month,
required this.totalBalance,
required this.availableAfterReserves,
required this.monthlyIncome,
required this.monthlyExpenses,
required this.monthlyBalance,
required this.provisionsCurrentTotal,
required this.provisionsTargetTotal,
required this.monthlyChargesRemainingTotal,
required this.monthlyTransferRemainingTotal,
required this.monthlyProvisionOperationRemainingTotal,
required this.scheduledExpensesRemainingTotal,
required this.scheduledIncomeRemainingTotal,
required this.scheduledTransferRemainingTotal,
required this.scheduledProvisionRemainingTotal,
required this.scheduledNetRemainingTotal,
required this.projectedMonthBalance,
required this.accounts,
required this.reconciliationsToday,
});
double get plannedExpensesTotal =>
monthlyChargesRemainingTotal + scheduledExpensesRemainingTotal;
double get plannedTransfersTotal =>
monthlyTransferRemainingTotal + scheduledTransferRemainingTotal;
double get plannedProvisionsTotal =>
monthlyProvisionOperationRemainingTotal + scheduledProvisionRemainingTotal;
bool get hasScheduledInfo =>
scheduledExpensesRemainingTotal != 0 ||
scheduledIncomeRemainingTotal != 0 ||
scheduledTransferRemainingTotal != 0 ||
scheduledProvisionRemainingTotal != 0;
factory DashboardData.fromJson(Map<String, dynamic> json) {
final availableRaw = json['available_after_reserves'] ?? json['total_balance'] ?? 0;
final monthlyBalance = (json['monthly_balance'] as num? ?? 0).toDouble();
final scheduledIncome = (json['scheduled_income_remaining_total'] as num? ?? 0).toDouble();
final scheduledExpenses = (json['scheduled_expenses_remaining_total'] as num? ?? 0).toDouble();
return DashboardData(
month: json['month'] as String? ?? '',
totalBalance: (json['total_balance'] as num? ?? 0).toDouble(),
availableAfterReserves: (availableRaw as num).toDouble(),
monthlyIncome: (json['monthly_income'] as num? ?? 0).toDouble(),
monthlyExpenses: (json['monthly_expenses'] as num? ?? 0).toDouble(),
monthlyBalance: monthlyBalance,
provisionsCurrentTotal: (json['provisions_current_total'] as num? ?? 0).toDouble(),
provisionsTargetTotal: (json['provisions_target_total'] as num? ?? 0).toDouble(),
monthlyChargesRemainingTotal: (json['monthly_charges_remaining_total'] as num? ?? 0).toDouble(),
monthlyTransferRemainingTotal: (json['monthly_transfer_remaining_total'] as num? ?? 0).toDouble(),
monthlyProvisionOperationRemainingTotal: (json['monthly_provision_operation_remaining_total'] as num? ?? 0).toDouble(),
scheduledExpensesRemainingTotal: scheduledExpenses,
scheduledIncomeRemainingTotal: scheduledIncome,
scheduledTransferRemainingTotal: (json['scheduled_transfer_remaining_total'] as num? ?? 0).toDouble(),
scheduledProvisionRemainingTotal: (json['scheduled_provision_remaining_total'] as num? ?? 0).toDouble(),
scheduledNetRemainingTotal: (json['scheduled_net_remaining_total'] as num? ?? (scheduledIncome - scheduledExpenses)).toDouble(),
projectedMonthBalance: (json['projected_month_balance'] as num? ?? (monthlyBalance + scheduledIncome - scheduledExpenses)).toDouble(),
accounts: (json['accounts'] as List<dynamic>? ?? [])
.map((e) => Account.fromJson(e as Map<String, dynamic>))
.toList(),
reconciliationsToday: (json['reconciliations_today'] as List<dynamic>? ?? [])
.map((e) => ReconciliationStatus.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'month': month,
'total_balance': totalBalance,
'available_after_reserves': availableAfterReserves,
'monthly_income': monthlyIncome,
'monthly_expenses': monthlyExpenses,
'monthly_balance': monthlyBalance,
'provisions_current_total': provisionsCurrentTotal,
'provisions_target_total': provisionsTargetTotal,
'monthly_charges_remaining_total': monthlyChargesRemainingTotal,
'monthly_transfer_remaining_total': monthlyTransferRemainingTotal,
'monthly_provision_operation_remaining_total': monthlyProvisionOperationRemainingTotal,
'scheduled_expenses_remaining_total': scheduledExpensesRemainingTotal,
'scheduled_income_remaining_total': scheduledIncomeRemainingTotal,
'scheduled_transfer_remaining_total': scheduledTransferRemainingTotal,
'scheduled_provision_remaining_total': scheduledProvisionRemainingTotal,
'scheduled_net_remaining_total': scheduledNetRemainingTotal,
'projected_month_balance': projectedMonthBalance,
'accounts': accounts.map((e) => e.toJson()).toList(),
'reconciliations_today': reconciliationsToday.map((e) => e.toJson()).toList(),
};
}
DashboardData copyWith({
String? month,
double? totalBalance,
double? availableAfterReserves,
double? monthlyIncome,
double? monthlyExpenses,
double? monthlyBalance,
double? provisionsCurrentTotal,
double? provisionsTargetTotal,
double? monthlyChargesRemainingTotal,
double? monthlyTransferRemainingTotal,
double? monthlyProvisionOperationRemainingTotal,
double? scheduledExpensesRemainingTotal,
double? scheduledIncomeRemainingTotal,
double? scheduledTransferRemainingTotal,
double? scheduledProvisionRemainingTotal,
double? scheduledNetRemainingTotal,
double? projectedMonthBalance,
List<Account>? accounts,
List<ReconciliationStatus>? reconciliationsToday,
}) {
return DashboardData(
month: month ?? this.month,
totalBalance: totalBalance ?? this.totalBalance,
availableAfterReserves: availableAfterReserves ?? this.availableAfterReserves,
monthlyIncome: monthlyIncome ?? this.monthlyIncome,
monthlyExpenses: monthlyExpenses ?? this.monthlyExpenses,
monthlyBalance: monthlyBalance ?? this.monthlyBalance,
provisionsCurrentTotal: provisionsCurrentTotal ?? this.provisionsCurrentTotal,
provisionsTargetTotal: provisionsTargetTotal ?? this.provisionsTargetTotal,
monthlyChargesRemainingTotal: monthlyChargesRemainingTotal ?? this.monthlyChargesRemainingTotal,
monthlyTransferRemainingTotal: monthlyTransferRemainingTotal ?? this.monthlyTransferRemainingTotal,
monthlyProvisionOperationRemainingTotal: monthlyProvisionOperationRemainingTotal ?? this.monthlyProvisionOperationRemainingTotal,
scheduledExpensesRemainingTotal: scheduledExpensesRemainingTotal ?? this.scheduledExpensesRemainingTotal,
scheduledIncomeRemainingTotal: scheduledIncomeRemainingTotal ?? this.scheduledIncomeRemainingTotal,
scheduledTransferRemainingTotal: scheduledTransferRemainingTotal ?? this.scheduledTransferRemainingTotal,
scheduledProvisionRemainingTotal: scheduledProvisionRemainingTotal ?? this.scheduledProvisionRemainingTotal,
scheduledNetRemainingTotal: scheduledNetRemainingTotal ?? this.scheduledNetRemainingTotal,
projectedMonthBalance: projectedMonthBalance ?? this.projectedMonthBalance,
accounts: accounts ?? this.accounts,
reconciliationsToday: reconciliationsToday ?? this.reconciliationsToday,
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'dart:convert';
class PendingOperation {
final int id;
final String operationType;
final Map<String, dynamic> payload;
final DateTime createdAt;
final String status;
final String? error;
PendingOperation({
required this.id,
required this.operationType,
required this.payload,
required this.createdAt,
required this.status,
this.error,
});
factory PendingOperation.fromDbMap(Map<String, dynamic> map) {
return PendingOperation(
id: map['id'] as int,
operationType: map['operation_type'] as String,
payload: jsonDecode(map['payload_json'] as String) as Map<String, dynamic>,
createdAt: DateTime.parse(map['created_at'] as String),
status: map['status'] as String? ?? 'pending',
error: map['error'] as String?,
);
}
}
+120
View File
@@ -0,0 +1,120 @@
class TransactionItem {
final int id;
final String label;
final double amount;
final String type;
final int accountId;
final int? targetAccountId;
final int? categoryId;
final int? provisionId;
final DateTime date;
final String? note;
final bool isPending;
final int? localOperationId;
TransactionItem({
required this.id,
required this.label,
required this.amount,
required this.type,
required this.accountId,
required this.date,
this.targetAccountId,
this.categoryId,
this.provisionId,
this.note,
this.isPending = false,
this.localOperationId,
});
factory TransactionItem.fromJson(Map<String, dynamic> json) {
return TransactionItem(
id: json['id'] as int,
label: json['label'] as String,
amount: (json['amount'] as num? ?? 0).toDouble(),
type: json['type'] as String,
accountId: json['account_id'] as int,
targetAccountId: json['target_account_id'] as int?,
categoryId: json['category_id'] as int?,
provisionId: json['provision_id'] as int?,
date: DateTime.parse(json['date'] as String),
note: json['note'] as String?,
isPending: json['is_pending'] is int
? (json['is_pending'] as int) == 1
: json['is_pending'] as bool? ?? false,
localOperationId: json['local_operation_id'] as int?,
);
}
factory TransactionItem.fromPending({
required int localOperationId,
required Map<String, dynamic> payload,
}) {
return TransactionItem(
id: -localOperationId,
label: payload['label'] as String? ?? 'Opération mobile',
amount: (payload['amount'] as num? ?? 0).toDouble(),
type: payload['type'] as String? ?? 'expense',
accountId: payload['account_id'] as int,
targetAccountId: payload['target_account_id'] as int?,
categoryId: payload['category_id'] as int?,
provisionId: payload['provision_id'] as int?,
date: DateTime.tryParse(payload['date'] as String? ?? '') ?? DateTime.now(),
note: payload['note'] as String?,
isPending: true,
localOperationId: localOperationId,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'label': label,
'amount': amount,
'type': type,
'account_id': accountId,
'target_account_id': targetAccountId,
'category_id': categoryId,
'provision_id': provisionId,
'date': date.toIso8601String(),
'note': note,
'is_pending': isPending,
'local_operation_id': localOperationId,
};
}
Map<String, dynamic> toDbMap() {
return {
'id': id,
'label': label,
'amount': amount,
'type': type,
'account_id': accountId,
'target_account_id': targetAccountId,
'category_id': categoryId,
'provision_id': provisionId,
'date': date.toIso8601String(),
'note': note,
'is_pending': isPending ? 1 : 0,
'local_operation_id': localOperationId,
'updated_at': DateTime.now().toIso8601String(),
};
}
factory TransactionItem.fromDbMap(Map<String, dynamic> map) {
return TransactionItem(
id: map['id'] as int,
label: map['label'] as String,
amount: (map['amount'] as num? ?? 0).toDouble(),
type: map['type'] as String,
accountId: map['account_id'] as int,
targetAccountId: map['target_account_id'] as int?,
categoryId: map['category_id'] as int?,
provisionId: map['provision_id'] as int?,
date: DateTime.parse(map['date'] as String),
note: map['note'] as String?,
isPending: (map['is_pending'] as int? ?? 0) == 1,
localOperationId: map['local_operation_id'] as int?,
);
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/security_service.dart';
class AppLockGate extends StatefulWidget {
const AppLockGate({
super.key,
required this.securityService,
required this.child,
});
final SecurityService securityService;
final Widget child;
@override
State<AppLockGate> createState() => _AppLockGateState();
}
class _AppLockGateState extends State<AppLockGate> with WidgetsBindingObserver {
bool _checking = true;
bool _locked = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadLockState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
_lockIfNeeded();
}
}
Future<void> _loadLockState() async {
final enabled = await widget.securityService.isPinEnabled();
if (!mounted) return;
setState(() {
_locked = enabled;
_checking = false;
});
}
Future<void> _lockIfNeeded() async {
final enabled = await widget.securityService.isPinEnabled();
if (!mounted || !enabled) return;
setState(() => _locked = true);
}
void _unlock() {
setState(() => _locked = false);
}
@override
Widget build(BuildContext context) {
if (_checking) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_locked) {
return PinUnlockScreen(
securityService: widget.securityService,
onUnlocked: _unlock,
);
}
return widget.child;
}
}
class PinUnlockScreen extends StatefulWidget {
const PinUnlockScreen({
super.key,
required this.securityService,
required this.onUnlocked,
});
final SecurityService securityService;
final VoidCallback onUnlocked;
@override
State<PinUnlockScreen> createState() => _PinUnlockScreenState();
}
class _PinUnlockScreenState extends State<PinUnlockScreen> {
final _pinController = TextEditingController();
bool _checking = false;
bool _checkingBiometric = false;
bool _biometricEnabled = false;
bool _biometricAvailable = false;
String? _error;
@override
void initState() {
super.initState();
_loadBiometricState();
}
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
Future<void> _loadBiometricState() async {
final enabled = await widget.securityService.isBiometricEnabled();
final available = await widget.securityService.isBiometricAvailable();
if (!mounted) return;
setState(() {
_biometricEnabled = enabled;
_biometricAvailable = available;
});
}
Future<void> _unlockWithBiometrics() async {
if (_checkingBiometric) return;
setState(() {
_checkingBiometric = true;
_error = null;
});
final ok = await widget.securityService.authenticateWithBiometrics();
if (!mounted) return;
if (ok) {
widget.onUnlocked();
} else {
setState(() {
_checkingBiometric = false;
_error = 'Empreinte refusée ou annulée. Utilise le PIN.';
});
}
}
Future<void> _unlock() async {
if (_checking) return;
setState(() {
_checking = true;
_error = null;
});
final ok = await widget.securityService.verifyPin(_pinController.text.trim());
if (!mounted) return;
if (ok) {
_pinController.clear();
widget.onUnlocked();
} else {
HapticFeedback.vibrate();
setState(() {
_checking = false;
_error = 'PIN incorrect.';
});
}
}
@override
Widget build(BuildContext context) {
final showBiometric = _biometricEnabled && _biometricAvailable;
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(24),
children: [
Icon(showBiometric ? Icons.fingerprint : Icons.lock, size: 64),
const SizedBox(height: 16),
Text(
'FIN65 verrouillé',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
showBiometric
? 'Déverrouille avec ton empreinte ou ton PIN.'
: 'Entre ton PIN pour accéder aux données locales.',
textAlign: TextAlign.center,
),
if (showBiometric) ...[
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _checkingBiometric ? null : _unlockWithBiometrics,
icon: _checkingBiometric
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.fingerprint),
label: Text(_checkingBiometric ? 'Vérification...' : 'Utiliser l’empreinte'),
),
],
const SizedBox(height: 24),
TextField(
controller: _pinController,
autofocus: !showBiometric,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: 'PIN',
border: const OutlineInputBorder(),
errorText: _error,
counterText: '',
),
onSubmitted: (_) => _checking ? null : _unlock(),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _checking ? null : _unlock,
icon: _checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open),
label: Text(_checking ? 'Vérification...' : 'Déverrouiller avec le PIN'),
),
],
),
),
),
),
);
}
}
+715
View File
@@ -0,0 +1,715 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import '../services/sync_service.dart';
import 'quick_transaction_screen.dart';
import 'settings_screen.dart';
import 'sync_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.syncService});
final SyncService syncService;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DashboardData? _dashboard;
List<Account> _accounts = [];
List<Category> _categories = [];
List<TransactionItem> _transactions = [];
int _pendingCount = 0;
DateTime? _lastSyncAt;
bool _isOnline = false;
bool _loading = true;
bool _syncing = false;
String? _message;
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final local = await widget.syncService.loadLocalSnapshot(
isOnline: false,
message: 'Données locales chargées.',
);
_applySnapshot(local, loading: false);
await _synchronize();
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_message = 'Erreur locale : $e';
});
}
}
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
if (!mounted) return;
setState(() {
_dashboard = snapshot.dashboard;
_accounts = snapshot.accounts;
_categories = snapshot.categories;
_transactions = snapshot.transactions;
_pendingCount = snapshot.pendingCount;
_lastSyncAt = snapshot.lastSyncAt;
_isOnline = snapshot.isOnline;
_message = snapshot.message;
if (loading != null) _loading = loading;
});
}
Future<void> _synchronize() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation...';
});
try {
final snapshot = await widget.syncService.synchronize();
if (!mounted) return;
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Erreur de synchronisation : $e';
_loading = false;
});
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
Future<void> _reloadLocalAfterSave() async {
try {
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: _isOnline
? 'Opération enregistrée.'
: 'Opération en attente de synchronisation.',
);
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
_loading = false;
});
}
}
Future<void> _openQuickAdd() async {
if (_accounts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => QuickTransactionScreen(
syncService: widget.syncService,
accounts: _accounts,
categories: _categories,
onSaved: _reloadLocalAfterSave,
),
),
);
}
Future<void> _openSyncScreen() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SyncScreen(syncService: widget.syncService),
),
);
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: null,
);
_applySnapshot(snapshot, loading: false);
}
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
),
);
await _synchronize();
}
@override
Widget build(BuildContext context) {
final dashboard = _dashboard;
return Scaffold(
appBar: AppBar(
title: const Text('FIN65'),
actions: [
IconButton(
tooltip: 'Page synchronisation',
onPressed: _openSyncScreen,
icon: Badge(
isLabelVisible: _pendingCount > 0,
label: Text('$_pendingCount'),
child: const Icon(Icons.cloud_sync),
),
),
IconButton(
tooltip: 'Synchroniser',
onPressed: _syncing ? null : _synchronize,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openQuickAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
body: RefreshIndicator(
onRefresh: _synchronize,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_SyncBanner(
isOnline: _isOnline,
pendingCount: _pendingCount,
lastSyncAt: _lastSyncAt,
formatter: _date,
message: _message,
),
const SizedBox(height: 12),
if (dashboard != null) ...[
_DashboardCards(
dashboard: dashboard,
money: _money,
hasPending: _pendingCount > 0,
),
const SizedBox(height: 16),
] else
const _EmptyCacheCard(),
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
const SizedBox(height: 8),
if (_accounts.isEmpty)
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
else
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
const SizedBox(height: 16),
_SectionTitle(title: 'Dernières transactions'),
const SizedBox(height: 8),
if (_transactions.isEmpty)
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
else
..._transactions.map(
(t) => _TransactionTile(
transaction: t,
accounts: _accounts,
categories: _categories,
money: _money,
),
),
const SizedBox(height: 90),
],
),
),
);
}
}
class _SyncBanner extends StatelessWidget {
const _SyncBanner({
required this.isOnline,
required this.pendingCount,
required this.lastSyncAt,
required this.formatter,
this.message,
});
final bool isOnline;
final int pendingCount;
final DateTime? lastSyncAt;
final DateFormat formatter;
final String? message;
@override
Widget build(BuildContext context) {
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
final subtitleParts = <String>[];
if (lastSyncAt != null) {
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
} else {
subtitleParts.add('Aucune synchronisation réussie');
}
if (pendingCount > 0) {
subtitleParts.add('$pendingCount opération(s) en attente');
}
if (message != null && message!.isNotEmpty) {
subtitleParts.add(message!);
}
return Card(
child: ListTile(
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(title),
subtitle: Text(subtitleParts.join('\n')),
),
);
}
}
class _EmptyCacheCard extends StatelessWidget {
const _EmptyCacheCard();
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
),
),
);
}
}
class _DashboardCards extends StatelessWidget {
const _DashboardCards({
required this.dashboard,
required this.money,
required this.hasPending,
});
final DashboardData dashboard;
final NumberFormat money;
final bool hasPending;
@override
Widget build(BuildContext context) {
final labelSuffix = hasPending ? ' estimé' : '';
return Column(
children: [
_MetricCard(
title: 'Disponible réel$labelSuffix',
value: money.format(dashboard.availableAfterReserves),
icon: Icons.account_balance_wallet,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
backgroundColor: const Color(0xFFE3F2FD),
accentColor: const Color(0xFF1565C0),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
backgroundColor: const Color(0xFFF3E5F5),
accentColor: const Color(0xFF6A1B9A),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
backgroundColor: dashboard.monthlyBalance >= 0
? const Color(0xFFE8F5E9)
: const Color(0xFFFFEBEE),
accentColor: dashboard.monthlyBalance >= 0
? const Color(0xFF2E7D32)
: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
backgroundColor: const Color(0xFFFFEBEE),
accentColor: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
_PlannedInfoCard(dashboard: dashboard, money: money),
],
);
}
}
class _PlannedInfoCard extends StatelessWidget {
const _PlannedInfoCard({
required this.dashboard,
required this.money,
});
final DashboardData dashboard;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final rows = <_InfoRow>[
_InfoRow(
'Dépenses prévues restantes',
dashboard.plannedExpensesTotal,
Icons.event_busy,
const Color(0xFFFFEBEE),
const Color(0xFFC62828),
),
_InfoRow(
'Revenus prévus échéancier',
dashboard.scheduledIncomeRemainingTotal,
Icons.event_available,
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
),
_InfoRow(
'Dépenses échéancier',
dashboard.scheduledExpensesRemainingTotal,
Icons.calendar_month,
const Color(0xFFFFF3E0),
const Color(0xFFEF6C00),
),
_InfoRow(
'Transferts prévus',
dashboard.plannedTransfersTotal,
Icons.swap_horiz,
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
),
_InfoRow(
'Provisions prévues',
dashboard.plannedProvisionsTotal,
Icons.inventory_2,
const Color(0xFFF3E5F5),
const Color(0xFF6A1B9A),
),
];
return Card(
color: const Color(0xFFFAFAFA),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.event_note, color: Color(0xFF1565C0)),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 20),
...rows.map(
(row) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: row.backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: row.accentColor.withOpacity(0.22)),
),
child: Row(
children: [
Icon(row.icon, size: 20, color: row.accentColor),
const SizedBox(width: 10),
Expanded(
child: Text(
row.label,
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w700,
),
),
),
Text(
money.format(row.value),
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(
this.label,
this.value,
this.icon,
this.backgroundColor,
this.accentColor,
);
final String label;
final double value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.backgroundColor,
required this.accentColor,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
color: backgroundColor,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Container(
width: compact ? 34 : 42,
height: compact ? 34 : 42,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: compact ? 21 : 27, color: accentColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
Text(
value,
style: (compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall)
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, this.action});
final String title;
final String? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({required this.account, required this.money});
final Account account;
final NumberFormat money;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(account.name),
subtitle: Text(account.type),
trailing: Text(
money.format(account.appBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({
required this.transaction,
required this.accounts,
required this.categories,
required this.money,
});
final TransactionItem transaction;
final List<Account> accounts;
final List<Category> categories;
final NumberFormat money;
Account? _findAccount(int? id) {
if (id == null) return null;
for (final account in accounts) {
if (account.id == id) return account;
}
return null;
}
Category? _findCategory(int? id) {
if (id == null) return null;
for (final category in categories) {
if (category.id == id) return category;
}
return null;
}
@override
Widget build(BuildContext context) {
final account = _findAccount(transaction.accountId);
final target = _findAccount(transaction.targetAccountId);
final category = _findCategory(transaction.categoryId);
final typeLabel = switch (transaction.type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => transaction.type,
};
final accountLabel = transaction.type == 'transfer'
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
: account?.name ?? 'Compte ${transaction.accountId}';
return Card(
child: ListTile(
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
title: Text(transaction.label),
subtitle: Text(
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
),
trailing: Text(
money.format(transaction.amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
+452
View File
@@ -0,0 +1,452 @@
import 'package:flutter/material.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../services/sync_service.dart';
class QuickTransactionScreen extends StatefulWidget {
const QuickTransactionScreen({
super.key,
required this.syncService,
required this.accounts,
required this.categories,
required this.onSaved,
});
final SyncService syncService;
final List<Account> accounts;
final List<Category> categories;
/// Important: this is async because the home screen must reload the local
/// SQLite snapshot before the quick form is closed.
final Future<void> Function() onSaved;
@override
State<QuickTransactionScreen> createState() => _QuickTransactionScreenState();
}
class _QuickTransactionScreenState extends State<QuickTransactionScreen> {
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _labelController = TextEditingController();
final _noteController = TextEditingController();
String _type = 'expense';
int? _accountId;
int? _targetAccountId;
int? _categoryId;
bool _saving = false;
static const _quickAmounts = <double>[10, 20, 50, 100, 200, 500, 1000];
static const _expenseTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Courses', categoryHint: 'course'),
_QuickTemplate(label: 'Essence', categoryHint: 'essence'),
_QuickTemplate(label: 'Restaurant', categoryHint: 'restaurant'),
_QuickTemplate(label: 'Café', categoryHint: 'café'),
_QuickTemplate(label: 'Transport', categoryHint: 'transport'),
_QuickTemplate(label: 'Santé', categoryHint: 'santé'),
_QuickTemplate(label: 'Abonnement', categoryHint: 'abonnement'),
_QuickTemplate(label: 'Retrait cash', categoryHint: 'cash'),
];
static const _incomeTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Salaire', categoryHint: 'salaire'),
_QuickTemplate(label: 'Remboursement', categoryHint: 'remboursement'),
_QuickTemplate(label: 'Prime', categoryHint: 'prime'),
_QuickTemplate(label: 'Vente', categoryHint: 'vente'),
];
static const _transferTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Épargne'),
_QuickTemplate(label: 'Provision'),
_QuickTemplate(label: 'Banque → Cash'),
_QuickTemplate(label: 'Cash → Banque'),
];
@override
void initState() {
super.initState();
if (widget.accounts.isNotEmpty) {
_accountId = widget.accounts.first.id;
_targetAccountId = widget.accounts.length > 1 ? widget.accounts[1].id : null;
}
_setDefaultCategory();
}
@override
void dispose() {
_amountController.dispose();
_labelController.dispose();
_noteController.dispose();
super.dispose();
}
List<_QuickTemplate> get _templates {
if (_type == 'income') return _incomeTemplates;
if (_type == 'transfer') return _transferTemplates;
return _expenseTemplates;
}
void _setDefaultCategory({String? preferredHint}) {
final filtered = widget.categories.where((c) => c.type == _type).toList();
if (filtered.isEmpty) {
_categoryId = null;
return;
}
if (preferredHint != null && preferredHint.trim().isNotEmpty) {
final normalizedHint = _normalize(preferredHint);
for (final category in filtered) {
final normalizedCategory = _normalize(category.name);
if (normalizedCategory.contains(normalizedHint) ||
normalizedHint.contains(normalizedCategory)) {
_categoryId = category.id;
return;
}
}
}
_categoryId = filtered.first.id;
}
String _normalize(String value) {
return value
.toLowerCase()
.replaceAll('é', 'e')
.replaceAll('è', 'e')
.replaceAll('ê', 'e')
.replaceAll('à', 'a')
.replaceAll('â', 'a')
.replaceAll('ù', 'u')
.replaceAll('û', 'u')
.replaceAll('î', 'i')
.replaceAll('ï', 'i')
.replaceAll('ô', 'o')
.replaceAll('ç', 'c')
.trim();
}
List<Category> get _visibleCategories {
return widget.categories.where((c) => c.type == _type).toList();
}
void _applyTemplate(_QuickTemplate template) {
if (_saving) return;
setState(() {
_labelController.text = template.label;
if (template.amount != null) {
_amountController.text = _formatAmount(template.amount!);
}
if (_type != 'transfer') {
_setDefaultCategory(preferredHint: template.categoryHint ?? template.label);
}
});
}
void _applyAmount(double amount) {
if (_saving) return;
setState(() {
_amountController.text = _formatAmount(amount);
});
}
String _formatAmount(double amount) {
if (amount == amount.roundToDouble()) return amount.toInt().toString();
return amount.toStringAsFixed(2);
}
int? _firstOtherAccountId() {
for (final account in widget.accounts) {
if (account.id != _accountId) return account.id;
}
return null;
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final accountId = _accountId;
if (accountId == null) {
_showSnack('Compte obligatoire.');
return;
}
final targetAccountId = _targetAccountId;
if (_type == 'transfer') {
if (targetAccountId == null) {
_showSnack('Compte cible obligatoire.');
return;
}
if (targetAccountId == accountId) {
_showSnack('Source et cible doivent être différents.');
return;
}
}
setState(() => _saving = true);
try {
final synced = await widget.syncService.createTransactionSmart(
label: _labelController.text.trim(),
amount: double.parse(_amountController.text.replaceAll(',', '.')),
type: _type,
accountId: accountId,
targetAccountId: _type == 'transfer' ? targetAccountId : null,
categoryId: _type == 'transfer' ? null : _categoryId,
note: _noteController.text.trim().isEmpty ? null : _noteController.text.trim(),
);
if (!mounted) return;
await widget.onSaved();
if (!mounted) return;
_showSnack(
synced
? 'Transaction synchronisée.'
: 'Backend absent : opération gardée localement.',
);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (!mounted) return;
_showSnack('Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _showSnack(String text) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.clearSnackBars();
messenger?.showSnackBar(SnackBar(content: Text(text)));
}
Widget _buildSaveButton() {
return FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(_saving ? 'Enregistrement...' : 'Enregistrer'),
);
}
@override
Widget build(BuildContext context) {
final categories = _visibleCategories;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
title: const Text('Ajout rapide'),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: _buildSaveButton(),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'expense', label: Text('Dépense')),
ButtonSegment(value: 'income', label: Text('Recette')),
ButtonSegment(value: 'transfer', label: Text('Transfert')),
],
selected: {_type},
onSelectionChanged: _saving
? null
: (values) {
setState(() {
_type = values.first;
_setDefaultCategory();
if (_type == 'transfer' && _targetAccountId == _accountId) {
_targetAccountId = _firstOtherAccountId();
}
});
},
),
const SizedBox(height: 16),
_QuickChipsSection(
title: _type == 'transfer'
? 'Transferts rapides'
: _type == 'income'
? 'Recettes rapides'
: 'Dépenses rapides',
chips: _templates
.map((template) => ActionChip(
label: Text(template.label),
onPressed: _saving ? null : () => _applyTemplate(template),
))
.toList(),
),
const SizedBox(height: 16),
TextFormField(
controller: _amountController,
decoration: const InputDecoration(
labelText: 'Montant',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
final parsed = double.tryParse((value ?? '').replaceAll(',', '.'));
if (parsed == null || parsed <= 0) return 'Montant invalide';
return null;
},
),
const SizedBox(height: 8),
_QuickChipsSection(
title: 'Montants rapides',
chips: _quickAmounts
.map((amount) => ChoiceChip(
label: Text(_formatAmount(amount)),
selected: _amountController.text == _formatAmount(amount),
onSelected: _saving ? null : (_) => _applyAmount(amount),
))
.toList(),
dense: true,
),
const SizedBox(height: 12),
TextFormField(
controller: _labelController,
decoration: const InputDecoration(
labelText: 'Libellé',
border: OutlineInputBorder(),
),
validator: (value) {
if ((value ?? '').trim().isEmpty) return 'Libellé obligatoire';
return null;
},
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _accountId,
decoration: const InputDecoration(
labelText: 'Compte source',
border: OutlineInputBorder(),
),
items: widget.accounts
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
.toList(),
onChanged: _saving
? null
: (value) {
setState(() {
_accountId = value;
if (_type == 'transfer' && _targetAccountId == _accountId) {
_targetAccountId = _firstOtherAccountId();
}
});
},
validator: (value) => value == null ? 'Compte obligatoire' : null,
),
if (_type == 'transfer') ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _targetAccountId,
decoration: const InputDecoration(
labelText: 'Compte cible',
border: OutlineInputBorder(),
),
items: widget.accounts
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
.toList(),
onChanged: _saving ? null : (value) => setState(() => _targetAccountId = value),
validator: (value) {
if (value == null) return 'Compte cible obligatoire';
if (value == _accountId) return 'Source et cible doivent être différents';
return null;
},
),
],
if (_type != 'transfer') ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _categoryId,
decoration: const InputDecoration(
labelText: 'Catégorie',
border: OutlineInputBorder(),
),
items: categories
.map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
.toList(),
onChanged: _saving ? null : (value) => setState(() => _categoryId = value),
),
],
const SizedBox(height: 12),
TextFormField(
controller: _noteController,
decoration: const InputDecoration(
labelText: 'Note',
border: OutlineInputBorder(),
),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 120),
],
),
),
);
}
}
class _QuickTemplate {
const _QuickTemplate({
required this.label,
this.categoryHint,
this.amount,
});
final String label;
final String? categoryHint;
final double? amount;
}
class _QuickChipsSection extends StatelessWidget {
const _QuickChipsSection({
required this.title,
required this.chips,
this.dense = false,
});
final String title;
final List<Widget> chips;
final bool dense;
@override
Widget build(BuildContext context) {
if (chips.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: dense
? Theme.of(context).textTheme.bodyMedium
: Theme.of(context).textTheme.titleSmall,
),
SizedBox(height: dense ? 4 : 8),
Wrap(
spacing: 8,
runSpacing: dense ? 4 : 8,
children: chips,
),
],
);
}
}
+311
View File
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import '../services/security_service.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({
super.key,
required this.securityService,
});
final SecurityService securityService;
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _currentPinController = TextEditingController();
final _newPinController = TextEditingController();
final _confirmPinController = TextEditingController();
bool _loading = true;
bool _saving = false;
bool _enabled = false;
bool _biometricAvailable = false;
bool _biometricEnabled = false;
List<BiometricType> _availableBiometrics = const [];
String? _message;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_currentPinController.dispose();
_newPinController.dispose();
_confirmPinController.dispose();
super.dispose();
}
Future<void> _load() async {
final enabled = await widget.securityService.isPinEnabled();
final biometricAvailable = await widget.securityService.isBiometricAvailable();
final biometricEnabled = await widget.securityService.isBiometricEnabled();
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
if (!mounted) return;
setState(() {
_enabled = enabled;
_biometricAvailable = biometricAvailable;
_biometricEnabled = biometricEnabled;
_availableBiometrics = availableBiometrics;
_loading = false;
});
}
Future<bool> _verifyCurrentPinFromField() async {
final current = _currentPinController.text.trim();
final ok = await widget.securityService.verifyPin(current);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<void> _savePin() async {
final newPin = _newPinController.text.trim();
final confirm = _confirmPinController.text.trim();
if (newPin != confirm) {
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
return;
}
setState(() {
_saving = true;
_message = null;
});
try {
if (_enabled) {
final currentOk = await _verifyCurrentPinFromField();
if (!currentOk) return;
}
await widget.securityService.setPin(newPin);
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN activé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _disablePin() async {
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _verifyCurrentPinFromField();
if (!currentOk) return;
await widget.securityService.disablePin();
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN et biométrie désactivés.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _toggleBiometrics(bool value) async {
if (_saving) return;
setState(() {
_saving = true;
_message = null;
});
try {
if (!_enabled) {
setState(() => _message = 'Active d’abord un PIN avant la biométrie.');
return;
}
if (value && !_biometricAvailable) {
setState(() => _message = 'Aucune empreinte/biométrie disponible sur ce téléphone.');
return;
}
final currentOk = await _verifyCurrentPinFromField();
if (!currentOk) return;
await widget.securityService.setBiometricEnabled(value);
_currentPinController.clear();
await _load();
setState(() {
_message = value
? 'Déverrouillage par empreinte activé. Verrouille l’app puis teste avec le bouton empreinte.'
: 'Déverrouillage par empreinte désactivé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
String _biometricLabel() {
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
final labels = _availableBiometrics.map((type) {
return switch (type) {
BiometricType.fingerprint => 'empreinte',
BiometricType.face => 'visage',
BiometricType.iris => 'iris',
BiometricType.strong => 'biométrie forte',
BiometricType.weak => 'biométrie faible',
};
}).toSet().join(', ');
return 'Disponible : $labels.';
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: SwitchListTile(
value: _enabled,
onChanged: null,
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
title: const Text('Verrouillage PIN'),
subtitle: Text(
_enabled
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
),
),
),
if (_enabled) ...[
const SizedBox(height: 12),
Card(
child: SwitchListTile(
value: _biometricEnabled,
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
secondary: const Icon(Icons.fingerprint),
title: const Text('Déverrouillage par empreinte'),
subtitle: Text(
_biometricAvailable
? '${_biometricLabel()} Entre le PIN actuel ci-dessous puis active l’interrupteur.'
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
),
),
),
],
const SizedBox(height: 16),
if (_enabled) ...[
TextField(
controller: _currentPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
helperText: 'Nécessaire pour changer le PIN, désactiver le PIN ou activer/désactiver l’empreinte.',
counterText: '',
),
),
const SizedBox(height: 12),
],
TextField(
controller: _newPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
border: const OutlineInputBorder(),
helperText: '4 à 8 chiffres.',
counterText: '',
),
),
const SizedBox(height: 12),
TextField(
controller: _confirmPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Confirmer le PIN',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _saving ? null : _savePin,
icon: const Icon(Icons.save),
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
),
if (_enabled) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _saving ? null : _disablePin,
icon: const Icon(Icons.lock_open),
label: const Text('Désactiver le PIN'),
),
],
if (_message != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_message!),
),
),
],
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
),
),
),
],
),
);
}
}
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import '../services/security_service.dart';
class SecurityScreen extends StatefulWidget {
const SecurityScreen({
super.key,
required this.securityService,
});
final SecurityService securityService;
@override
State<SecurityScreen> createState() => _SecurityScreenState();
}
class _SecurityScreenState extends State<SecurityScreen> {
final _currentPinController = TextEditingController();
final _newPinController = TextEditingController();
final _confirmPinController = TextEditingController();
bool _loading = true;
bool _saving = false;
bool _enabled = false;
bool _biometricAvailable = false;
bool _biometricEnabled = false;
List<BiometricType> _availableBiometrics = const [];
String? _message;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_currentPinController.dispose();
_newPinController.dispose();
_confirmPinController.dispose();
super.dispose();
}
Future<void> _load() async {
final enabled = await widget.securityService.isPinEnabled();
final biometricAvailable = await widget.securityService.isBiometricAvailable();
final biometricEnabled = await widget.securityService.isBiometricEnabled();
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
if (!mounted) return;
setState(() {
_enabled = enabled;
_biometricAvailable = biometricAvailable;
_biometricEnabled = biometricEnabled;
_availableBiometrics = availableBiometrics;
_loading = false;
});
}
Future<bool> _verifyCurrentPinIfNeeded() async {
if (!_enabled) return true;
final current = _currentPinController.text.trim();
final ok = await widget.securityService.verifyPin(current);
if (!ok) {
setState(() => _message = 'PIN actuel incorrect.');
return false;
}
return true;
}
Future<void> _savePin() async {
final newPin = _newPinController.text.trim();
final confirm = _confirmPinController.text.trim();
if (newPin != confirm) {
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
return;
}
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _verifyCurrentPinIfNeeded();
if (!currentOk) return;
await widget.securityService.setPin(newPin);
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN activé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _disablePin() async {
setState(() {
_saving = true;
_message = null;
});
try {
final currentOk = await _verifyCurrentPinIfNeeded();
if (!currentOk) return;
await widget.securityService.disablePin();
_currentPinController.clear();
_newPinController.clear();
_confirmPinController.clear();
await _load();
setState(() {
_message = 'Verrouillage PIN et biométrie désactivés.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _toggleBiometrics(bool value) async {
setState(() {
_saving = true;
_message = null;
});
try {
if (value) {
final currentOk = await _verifyCurrentPinIfNeeded();
if (!currentOk) return;
final ok = await widget.securityService.authenticateWithBiometrics();
// If biometric was not enabled yet, authenticateWithBiometrics returns false by design.
// So enable first after checking availability, then immediately ask Android dialog.
if (!ok) {
await widget.securityService.setBiometricEnabled(true);
final check = await widget.securityService.authenticateWithBiometrics();
if (!check) {
await widget.securityService.setBiometricEnabled(false);
setState(() => _message = 'Empreinte annulée ou refusée. Biométrie non activée.');
return;
}
} else {
await widget.securityService.setBiometricEnabled(true);
}
} else {
await widget.securityService.setBiometricEnabled(false);
}
await _load();
setState(() {
_message = value ? 'Déverrouillage par empreinte activé.' : 'Déverrouillage par empreinte désactivé.';
});
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
String _biometricLabel() {
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
final labels = _availableBiometrics.map((type) {
return switch (type) {
BiometricType.fingerprint => 'empreinte',
BiometricType.face => 'visage',
BiometricType.iris => 'iris',
BiometricType.strong => 'biométrie forte',
BiometricType.weak => 'biométrie faible',
};
}).toSet().join(', ');
return 'Disponible : $labels.';
}
@override
Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Sécurité')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: SwitchListTile(
value: _enabled,
onChanged: null,
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
title: const Text('Verrouillage PIN'),
subtitle: Text(
_enabled
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
),
),
),
if (_enabled) ...[
const SizedBox(height: 12),
Card(
child: SwitchListTile(
value: _biometricEnabled,
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
secondary: const Icon(Icons.fingerprint),
title: const Text('Déverrouillage par empreinte'),
subtitle: Text(
_biometricAvailable
? '${_biometricLabel()} Le PIN reste disponible en secours.'
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
),
),
),
],
const SizedBox(height: 16),
if (_enabled) ...[
TextField(
controller: _currentPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'PIN actuel',
border: OutlineInputBorder(),
helperText: 'Demandé pour changer le PIN, désactiver ou activer l’empreinte.',
counterText: '',
),
),
const SizedBox(height: 12),
],
TextField(
controller: _newPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: InputDecoration(
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
border: const OutlineInputBorder(),
helperText: '4 à 8 chiffres.',
counterText: '',
),
),
const SizedBox(height: 12),
TextField(
controller: _confirmPinController,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Confirmer le PIN',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _saving ? null : _savePin,
icon: const Icon(Icons.save),
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
),
if (_enabled) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _saving ? null : _disablePin,
icon: const Icon(Icons.lock_open),
label: const Text('Désactiver le PIN'),
),
],
if (_message != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_message!),
),
),
],
const SizedBox(height: 24),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
),
),
),
],
),
);
}
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../services/security_service.dart';
import 'security_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key, required this.apiService});
final ApiService apiService;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _controller = TextEditingController();
bool _testing = false;
String? _message;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
_controller.text = await widget.apiService.getBaseUrl();
if (mounted) setState(() {});
}
Future<void> _save() async {
await widget.apiService.setBaseUrl(_controller.text);
if (mounted) {
setState(() => _message = 'URL enregistrée localement.');
}
}
Future<void> _test() async {
setState(() {
_testing = true;
_message = null;
});
try {
await _save();
final ok = await widget.apiService.healthCheck();
setState(() => _message = ok ? 'Connexion OK.' : 'Réponse inattendue du serveur.');
} catch (e) {
setState(() => _message = 'Erreur : $e');
} finally {
if (mounted) setState(() => _testing = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'URL du backend FIN65',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
const Text(
'Émulateur Android : http://10.0.2.2:8000\nTéléphone réel : http://IP_DU_PC:8000',
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'URL API',
border: OutlineInputBorder(),
hintText: 'http://192.168.1.25:8000',
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
FilledButton(
onPressed: _testing ? null : _test,
child: Text(_testing ? 'Test...' : 'Enregistrer et tester'),
),
if (_message != null) ...[
const SizedBox(height: 16),
Text(_message!),
],
const SizedBox(height: 24),
Card(
child: ListTile(
leading: const Icon(Icons.lock),
title: const Text('Sécurité'),
subtitle: const Text('Activer, changer ou désactiver le verrouillage PIN.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SecurityScreen(
securityService: SecurityService(),
),
),
);
},
),
),
const SizedBox(height: 12),
const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'L’URL est stockée dans la base SQLite locale du mobile. '
'Elle reste donc disponible même sans backend.',
),
),
),
],
),
);
}
}
+239
View File
@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/pending_operation.dart';
import '../services/sync_service.dart';
class SyncScreen extends StatefulWidget {
const SyncScreen({
super.key,
required this.syncService,
});
final SyncService syncService;
@override
State<SyncScreen> createState() => _SyncScreenState();
}
class _SyncScreenState extends State<SyncScreen> {
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
bool _loading = true;
bool _syncing = false;
bool _isOnline = false;
String? _apiUrl;
String? _message;
DateTime? _lastSyncAt;
List<PendingOperation> _pending = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_message = null;
});
try {
final apiUrl = await widget.syncService.getApiUrl();
final isOnline = await widget.syncService.testBackend();
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: isOnline,
message: null,
);
final pending = await widget.syncService.loadPendingOperations();
if (!mounted) return;
setState(() {
_apiUrl = apiUrl;
_isOnline = isOnline;
_lastSyncAt = snapshot.lastSyncAt;
_pending = pending;
});
} catch (e) {
if (!mounted) return;
setState(() => _message = 'Erreur locale : $e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _syncNow() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation en cours...';
});
try {
final snapshot = await widget.syncService.synchronize();
final pending = await widget.syncService.loadPendingOperations();
final apiUrl = await widget.syncService.getApiUrl();
if (!mounted) return;
setState(() {
_apiUrl = apiUrl;
_isOnline = snapshot.isOnline;
_lastSyncAt = snapshot.lastSyncAt;
_pending = pending;
_message = snapshot.isOnline
? 'Synchronisation terminée.'
: 'Backend indisponible. Les opérations restent en attente.';
});
} catch (e) {
if (!mounted) return;
setState(() => _message = 'Erreur de synchronisation : $e');
} finally {
if (mounted) setState(() => _syncing = false);
}
}
@override
Widget build(BuildContext context) {
final pendingCount = _pending.length;
return Scaffold(
appBar: AppBar(
title: const Text('Synchronisation'),
actions: [
IconButton(
onPressed: _loading || _syncing ? null : _load,
icon: const Icon(Icons.refresh),
),
],
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: FilledButton.icon(
onPressed: _syncing ? null : _syncNow,
icon: _syncing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(_syncing ? 'Synchronisation...' : 'Synchroniser maintenant'),
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 110),
children: [
Card(
child: ListTile(
leading: Icon(_isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(_isOnline ? 'Backend joignable' : 'Backend indisponible'),
subtitle: Text(_apiUrl ?? 'URL API inconnue'),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.schedule),
title: const Text('Dernière synchronisation réussie'),
subtitle: Text(
_lastSyncAt == null
? 'Aucune synchronisation réussie'
: _date.format(_lastSyncAt!.toLocal()),
),
),
),
Card(
child: ListTile(
leading: Icon(pendingCount == 0 ? Icons.check_circle : Icons.pending_actions),
title: Text('$pendingCount opération(s) en attente'),
subtitle: Text(
pendingCount == 0
? 'Aucune opération locale à envoyer.'
: 'Ces opérations seront envoyées quand le backend sera disponible.',
),
),
),
if (_message != null && _message!.isNotEmpty) ...[
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_message!),
),
),
],
const SizedBox(height: 16),
Text('File d’attente', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
if (_pending.isEmpty)
const Card(
child: ListTile(
leading: Icon(Icons.inbox),
title: Text('Aucune opération en attente'),
),
)
else
..._pending.map((operation) => _PendingOperationTile(
operation: operation,
date: _date,
money: _money,
)),
],
),
);
}
}
class _PendingOperationTile extends StatelessWidget {
const _PendingOperationTile({
required this.operation,
required this.date,
required this.money,
});
final PendingOperation operation;
final DateFormat date;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final payload = operation.payload;
final label = payload['label']?.toString() ?? 'Opération mobile';
final amount = (payload['amount'] as num? ?? 0).toDouble();
final type = payload['type']?.toString() ?? operation.operationType;
final accountId = payload['account_id']?.toString() ?? '?';
final targetAccountId = payload['target_account_id']?.toString();
final typeLabel = switch (type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => type,
};
final accountLabel = type == 'transfer' && targetAccountId != null
? 'Compte $accountId → compte $targetAccountId'
: 'Compte $accountId';
return Card(
child: ListTile(
leading: const Icon(Icons.schedule_send),
title: Text(label),
subtitle: Text(
'$typeLabel • $accountLabel\nCréée le ${date.format(operation.createdAt.toLocal())}'
'${operation.error == null ? '' : '\nDernière erreur : ${operation.error}'}',
),
isThreeLine: true,
trailing: Text(
money.format(amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import 'offline_store.dart';
class ApiService {
ApiService({OfflineStore? store}) : _store = store ?? OfflineStore.instance;
final OfflineStore _store;
static const String _baseUrlKey = 'api_base_url';
Future<String> getBaseUrl() async {
return await _store.getSetting(_baseUrlKey) ?? AppConfig.defaultApiBaseUrl;
}
Future<void> setBaseUrl(String value) async {
await _store.setSetting(
_baseUrlKey,
value.trim().replaceAll(RegExp(r'/$'), ''),
);
}
Future<Uri> _uri(String path) async {
final baseUrl = await getBaseUrl();
return Uri.parse('$baseUrl$path');
}
Future<dynamic> _getJson(String path) async {
final res = await http.get(await _uri(path)).timeout(const Duration(seconds: 5));
return _handleResponse(res);
}
Future<dynamic> _postJson(String path, Map<String, dynamic> body) async {
final res = await http
.post(
await _uri(path),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 8));
return _handleResponse(res);
}
dynamic _handleResponse(http.Response res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
if (res.body.isEmpty) return null;
return jsonDecode(utf8.decode(res.bodyBytes));
}
String message = 'Erreur HTTP ${res.statusCode}';
try {
final body = jsonDecode(utf8.decode(res.bodyBytes));
message = body['detail']?.toString() ?? body.toString();
} catch (_) {}
throw Exception(message);
}
Future<bool> healthCheck() async {
final data = await _getJson('/health');
return data is Map && data['status'] == 'ok';
}
Future<DashboardData> getDashboard() async {
final data = await _getJson('/dashboard');
return DashboardData.fromJson(data as Map<String, dynamic>);
}
Future<List<Account>> getAccounts() async {
final data = await _getJson('/accounts?active_only=true') as List<dynamic>;
return data.map((e) => Account.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<Category>> getCategories() async {
final data = await _getJson('/categories') as List<dynamic>;
return data.map((e) => Category.fromJson(e as Map<String, dynamic>)).toList();
}
Future<List<TransactionItem>> getTransactions({int limit = AppConfig.defaultTransactionLimit}) async {
final data = await _getJson('/transactions?limit=$limit') as List<dynamic>;
return data.map((e) => TransactionItem.fromJson(e as Map<String, dynamic>)).toList();
}
Future<TransactionItem> createTransactionFromPayload(Map<String, dynamic> payload) async {
final cleanPayload = Map<String, dynamic>.from(payload);
cleanPayload.removeWhere((_, value) => value == null);
final data = await _postJson('/transactions', cleanPayload);
return TransactionItem.fromJson(data as Map<String, dynamic>);
}
Future<TransactionItem> createTransaction({
required String label,
required double amount,
required String type,
required int accountId,
int? targetAccountId,
int? categoryId,
String? note,
DateTime? date,
}) async {
return createTransactionFromPayload({
'date': (date ?? DateTime.now()).toIso8601String(),
'label': label,
'amount': amount,
'type': type,
'account_id': accountId,
'target_account_id': targetAccountId,
'category_id': categoryId,
'created_from': 'android',
'note': note,
});
}
}
+308
View File
@@ -0,0 +1,308 @@
import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/pending_operation.dart';
import '../models/transaction_item.dart';
class OfflineStore {
OfflineStore._();
static final OfflineStore instance = OfflineStore._();
Database? _database;
Future<Database> get database async {
final existing = _database;
if (existing != null) return existing;
final dbPath = await getDatabasesPath();
final path = p.join(dbPath, 'fin65_mobile.db');
final db = await openDatabase(
path,
version: 1,
onCreate: _createSchema,
);
_database = db;
return db;
}
Future<void> _createSchema(Database db, int version) async {
await db.execute('''
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE accounts_cache (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
currency TEXT NOT NULL,
initial_balance REAL NOT NULL,
app_balance REAL NOT NULL,
is_active INTEGER NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE categories_cache (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
parent_id INTEGER,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE transactions_cache (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
amount REAL NOT NULL,
type TEXT NOT NULL,
account_id INTEGER NOT NULL,
target_account_id INTEGER,
category_id INTEGER,
provision_id INTEGER,
date TEXT NOT NULL,
note TEXT,
is_pending INTEGER NOT NULL DEFAULT 0,
local_operation_id INTEGER,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE dashboard_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
json TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE pending_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT
)
''');
}
Future<String?> getSetting(String key) async {
final db = await database;
final rows = await db.query(
'settings',
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['value'] as String?;
}
Future<void> setSetting(String key, String value) async {
final db = await database;
await db.insert(
'settings',
{'key': key, 'value': value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<String?> getSyncState(String key) async {
final db = await database;
final rows = await db.query(
'sync_state',
where: 'key = ?',
whereArgs: [key],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['value'] as String?;
}
Future<void> setSyncState(String key, String value) async {
final db = await database;
await db.insert(
'sync_state',
{'key': key, 'value': value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<DateTime?> getLastSyncAt() async {
final value = await getSyncState('last_successful_sync_at');
return value == null ? null : DateTime.tryParse(value);
}
Future<void> saveServerSnapshot({
required DashboardData dashboard,
required List<Account> accounts,
required List<Category> categories,
required List<TransactionItem> transactions,
}) async {
final db = await database;
final now = DateTime.now().toIso8601String();
await db.transaction((txn) async {
await txn.delete('accounts_cache');
await txn.delete('categories_cache');
await txn.delete('transactions_cache');
await txn.delete('dashboard_cache');
for (final account in accounts) {
await txn.insert(
'accounts_cache',
account.toDbMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final category in categories) {
await txn.insert(
'categories_cache',
category.toDbMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
for (final transaction in transactions) {
await txn.insert(
'transactions_cache',
transaction.toDbMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await txn.insert(
'dashboard_cache',
{
'id': 1,
'json': jsonEncode(dashboard.toJson()),
'updated_at': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await txn.insert(
'sync_state',
{
'key': 'last_successful_sync_at',
'value': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
});
}
Future<List<Account>> loadAccounts() async {
final db = await database;
final rows = await db.query('accounts_cache', orderBy: 'name COLLATE NOCASE ASC');
return rows.map(Account.fromDbMap).toList();
}
Future<List<Category>> loadCategories() async {
final db = await database;
final rows = await db.query('categories_cache', orderBy: 'type ASC, name COLLATE NOCASE ASC');
return rows.map(Category.fromDbMap).toList();
}
Future<List<TransactionItem>> loadTransactions({int limit = 50}) async {
final db = await database;
final rows = await db.query(
'transactions_cache',
orderBy: 'date DESC, id DESC',
limit: limit,
);
return rows.map(TransactionItem.fromDbMap).toList();
}
Future<DashboardData?> loadDashboard() async {
final db = await database;
final rows = await db.query(
'dashboard_cache',
where: 'id = 1',
limit: 1,
);
if (rows.isEmpty) return null;
final decoded = jsonDecode(rows.first['json'] as String) as Map<String, dynamic>;
return DashboardData.fromJson(decoded);
}
Future<int> enqueueCreateTransaction(Map<String, dynamic> payload) async {
final db = await database;
return db.insert(
'pending_operations',
{
'operation_type': 'create_transaction',
'payload_json': jsonEncode(payload),
'created_at': DateTime.now().toIso8601String(),
'status': 'pending',
'error': null,
},
);
}
Future<List<PendingOperation>> loadPendingOperations() async {
final db = await database;
final rows = await db.query(
'pending_operations',
where: 'status = ?',
whereArgs: ['pending'],
orderBy: 'created_at ASC, id ASC',
);
return rows.map(PendingOperation.fromDbMap).toList();
}
Future<int> pendingCount() async {
final db = await database;
final result = await db.rawQuery(
"SELECT COUNT(*) AS count FROM pending_operations WHERE status = 'pending'",
);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<void> deletePendingOperation(int id) async {
final db = await database;
await db.delete(
'pending_operations',
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> markPendingError(int id, String error) async {
final db = await database;
await db.update(
'pending_operations',
{
'error': error,
'status': 'pending',
},
where: 'id = ?',
whereArgs: [id],
);
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:local_auth/local_auth.dart';
import 'offline_store.dart';
class SecurityService {
SecurityService({
OfflineStore? store,
LocalAuthentication? localAuth,
}) : _store = store ?? OfflineStore.instance,
_localAuth = localAuth ?? LocalAuthentication();
final OfflineStore _store;
final LocalAuthentication _localAuth;
static const String _pinEnabledKey = 'security_pin_enabled';
static const String _pinHashKey = 'security_pin_hash';
static const String _biometricEnabledKey = 'security_biometric_enabled';
static const String _salt = 'FIN65_LOCAL_PIN_V1';
Future<bool> isPinEnabled() async {
final enabled = await _store.getSetting(_pinEnabledKey);
final hash = await _store.getSetting(_pinHashKey);
return enabled == 'true' && hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
_validatePin(pin);
await _store.setSetting(_pinHashKey, _hashPin(pin));
await _store.setSetting(_pinEnabledKey, 'true');
}
Future<bool> verifyPin(String pin) async {
if (!_isValidPinShape(pin)) return false;
final storedHash = await _store.getSetting(_pinHashKey);
if (storedHash == null || storedHash.isEmpty) return false;
return storedHash == _hashPin(pin);
}
Future<void> disablePin() async {
await _store.setSetting(_pinHashKey, '');
await _store.setSetting(_pinEnabledKey, 'false');
await setBiometricEnabled(false);
}
Future<bool> isBiometricAvailable() async {
try {
final supported = await _localAuth.isDeviceSupported();
final canCheck = await _localAuth.canCheckBiometrics;
final biometrics = await _localAuth.getAvailableBiometrics();
return supported && canCheck && biometrics.isNotEmpty;
} catch (_) {
return false;
}
}
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (_) {
return const [];
}
}
Future<bool> isBiometricEnabled() async {
final enabled = await _store.getSetting(_biometricEnabledKey);
final pinEnabled = await isPinEnabled();
return pinEnabled && enabled == 'true';
}
Future<void> setBiometricEnabled(bool enabled) async {
if (enabled) {
final pinEnabled = await isPinEnabled();
if (!pinEnabled) {
throw Exception('Active d’abord un PIN avant la biométrie.');
}
final available = await isBiometricAvailable();
if (!available) {
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
}
}
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
}
Future<bool> authenticateWithBiometrics() async {
final enabled = await isBiometricEnabled();
if (!enabled) return false;
try {
return await _localAuth.authenticate(
localizedReason: 'Déverrouiller FIN65',
biometricOnly: true,
);
} catch (_) {
return false;
}
}
String _hashPin(String pin) {
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
}
void _validatePin(String pin) {
if (!_isValidPinShape(pin)) {
throw Exception('Le PIN doit contenir 4 à 8 chiffres.');
}
}
bool _isValidPinShape(String pin) {
return RegExp(r'^\d{4,8}$').hasMatch(pin);
}
}
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:local_auth/local_auth.dart';
import 'offline_store.dart';
class SecurityService {
SecurityService({
OfflineStore? store,
LocalAuthentication? localAuth,
}) : _store = store ?? OfflineStore.instance,
_localAuth = localAuth ?? LocalAuthentication();
final OfflineStore _store;
final LocalAuthentication _localAuth;
static const String _pinEnabledKey = 'security_pin_enabled';
static const String _pinHashKey = 'security_pin_hash';
static const String _biometricEnabledKey = 'security_biometric_enabled';
static const String _salt = 'FIN65_LOCAL_PIN_V1';
Future<bool> isPinEnabled() async {
final enabled = await _store.getSetting(_pinEnabledKey);
final hash = await _store.getSetting(_pinHashKey);
return enabled == 'true' && hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
_validatePin(pin);
await _store.setSetting(_pinHashKey, _hashPin(pin));
await _store.setSetting(_pinEnabledKey, 'true');
}
Future<bool> verifyPin(String pin) async {
if (!_isValidPinShape(pin)) return false;
final storedHash = await _store.getSetting(_pinHashKey);
if (storedHash == null || storedHash.isEmpty) return false;
return storedHash == _hashPin(pin);
}
Future<void> disablePin() async {
await _store.setSetting(_pinHashKey, '');
await _store.setSetting(_pinEnabledKey, 'false');
await setBiometricEnabled(false);
}
Future<bool> isBiometricAvailable() async {
try {
final supported = await _localAuth.isDeviceSupported();
final canCheck = await _localAuth.canCheckBiometrics;
final biometrics = await _localAuth.getAvailableBiometrics();
return supported && canCheck && biometrics.isNotEmpty;
} catch (_) {
return false;
}
}
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (_) {
return const [];
}
}
Future<bool> isBiometricEnabled() async {
final enabled = await _store.getSetting(_biometricEnabledKey);
final pinEnabled = await isPinEnabled();
return pinEnabled && enabled == 'true';
}
Future<void> setBiometricEnabled(bool enabled) async {
if (enabled) {
final pinEnabled = await isPinEnabled();
if (!pinEnabled) {
throw Exception('Active d’abord un PIN avant la biométrie.');
}
final available = await isBiometricAvailable();
if (!available) {
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
}
}
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
}
Future<bool> authenticateWithBiometrics() async {
final enabled = await isBiometricEnabled();
if (!enabled) return false;
try {
return await _localAuth.authenticate(
localizedReason: 'Déverrouiller FIN65',
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
useErrorDialogs: true,
),
);
} catch (_) {
return false;
}
}
String _hashPin(String pin) {
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
}
void _validatePin(String pin) {
if (!_isValidPinShape(pin)) {
throw Exception('Le PIN doit contenir 4 à 8 chiffres.');
}
}
bool _isValidPinShape(String pin) {
return RegExp(r'^\d{4,8}$').hasMatch(pin);
}
}
@@ -0,0 +1,119 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:local_auth/local_auth.dart';
import 'offline_store.dart';
class SecurityService {
SecurityService({
OfflineStore? store,
LocalAuthentication? localAuth,
}) : _store = store ?? OfflineStore.instance,
_localAuth = localAuth ?? LocalAuthentication();
final OfflineStore _store;
final LocalAuthentication _localAuth;
static const String _pinEnabledKey = 'security_pin_enabled';
static const String _pinHashKey = 'security_pin_hash';
static const String _biometricEnabledKey = 'security_biometric_enabled';
static const String _salt = 'FIN65_LOCAL_PIN_V1';
Future<bool> isPinEnabled() async {
final enabled = await _store.getSetting(_pinEnabledKey);
final hash = await _store.getSetting(_pinHashKey);
return enabled == 'true' && hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
_validatePin(pin);
await _store.setSetting(_pinHashKey, _hashPin(pin));
await _store.setSetting(_pinEnabledKey, 'true');
}
Future<bool> verifyPin(String pin) async {
if (!_isValidPinShape(pin)) return false;
final storedHash = await _store.getSetting(_pinHashKey);
if (storedHash == null || storedHash.isEmpty) return false;
return storedHash == _hashPin(pin);
}
Future<void> disablePin() async {
await _store.setSetting(_pinHashKey, '');
await _store.setSetting(_pinEnabledKey, 'false');
await setBiometricEnabled(false);
}
Future<bool> isBiometricAvailable() async {
try {
final supported = await _localAuth.isDeviceSupported();
final canCheck = await _localAuth.canCheckBiometrics;
final biometrics = await _localAuth.getAvailableBiometrics();
return supported && canCheck && biometrics.isNotEmpty;
} catch (_) {
return false;
}
}
Future<List<BiometricType>> getAvailableBiometrics() async {
try {
return await _localAuth.getAvailableBiometrics();
} catch (_) {
return const [];
}
}
Future<bool> isBiometricEnabled() async {
final enabled = await _store.getSetting(_biometricEnabledKey);
final pinEnabled = await isPinEnabled();
return pinEnabled && enabled == 'true';
}
Future<void> setBiometricEnabled(bool enabled) async {
if (enabled) {
final pinEnabled = await isPinEnabled();
if (!pinEnabled) {
throw Exception('Active d’abord un PIN avant la biométrie.');
}
final available = await isBiometricAvailable();
if (!available) {
throw Exception('Aucune empreinte/biométrie disponible sur cet appareil.');
}
}
await _store.setSetting(_biometricEnabledKey, enabled ? 'true' : 'false');
}
Future<bool> authenticateWithBiometrics() async {
final enabled = await isBiometricEnabled();
if (!enabled) return false;
try {
return await _localAuth.authenticate(
localizedReason: 'Déverrouiller FIN65',
biometricOnly: true,
stickyAuth: true,
useErrorDialogs: true,
);
} catch (_) {
return false;
}
}
String _hashPin(String pin) {
return sha256.convert(utf8.encode('$_salt::$pin')).toString();
}
void _validatePin(String pin) {
if (!_isValidPinShape(pin)) {
throw Exception('Le PIN doit contenir 4 à 8 chiffres.');
}
}
bool _isValidPinShape(String pin) {
return RegExp(r'^\d{4,8}$').hasMatch(pin);
}
}
+271
View File
@@ -0,0 +1,271 @@
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,
);
}
}