Initial upload: fin65-mobile
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
class AppConfig {
|
||||
static const String defaultApiBaseUrl = 'http://10.0.2.2:8000';
|
||||
static const int defaultTransactionLimit = 50;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user