feat: WebSocket temps réel + Finance Workflow + corrections

- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics)
  * Backend: KafkaEventProducer, KafkaEventConsumer
  * Mobile: WebSocketService (reconnection, heartbeat, typed events)
  * DashboardBloc: Auto-refresh depuis WebSocket events

- Finance Workflow: approbations + budgets (backend + mobile)
  * Backend: entities, services, resources, migrations Flyway V6
  * Mobile: features finance_workflow complète avec BLoC

- Corrections DI: interfaces IRepository partout
  * IProfileRepository, IOrganizationRepository, IMembreRepository
  * GetIt configuré avec @injectable

- Spec-Kit: constitution + templates mis à jour
  * .specify/memory/constitution.md enrichie
  * Templates agent, plan, spec, tasks, checklist

- Nettoyage: fichiers temporaires supprimés

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -0,0 +1,63 @@
/// Modèle de configuration des sauvegardes
/// Correspond à BackupConfigResponse du backend
library backup_config_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_config_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupConfigModel extends Equatable {
final bool? autoBackupEnabled;
final String? frequency; // HOURLY, DAILY, WEEKLY
final String? retention;
final int? retentionDays;
final String? backupTime;
final bool? includeDatabase;
final bool? includeFiles;
final bool? includeConfiguration;
final DateTime? lastBackup;
final DateTime? nextScheduledBackup;
final int? totalBackups;
final int? totalSizeBytes;
final String? totalSizeFormatted;
const BackupConfigModel({
this.autoBackupEnabled,
this.frequency,
this.retention,
this.retentionDays,
this.backupTime,
this.includeDatabase,
this.includeFiles,
this.includeConfiguration,
this.lastBackup,
this.nextScheduledBackup,
this.totalBackups,
this.totalSizeBytes,
this.totalSizeFormatted,
});
factory BackupConfigModel.fromJson(Map<String, dynamic> json) =>
_$BackupConfigModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupConfigModelToJson(this);
@override
List<Object?> get props => [
autoBackupEnabled,
frequency,
retention,
retentionDays,
backupTime,
includeDatabase,
includeFiles,
includeConfiguration,
lastBackup,
nextScheduledBackup,
totalBackups,
totalSizeBytes,
totalSizeFormatted,
];
}

View File

@@ -0,0 +1,69 @@
/// Modèle de sauvegarde
/// Correspond à BackupResponse du backend
library backup_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupModel extends Equatable {
final String? id;
final String? name;
final String? description;
final String? type; // AUTO, MANUAL, RESTORE_POINT
final int? sizeBytes;
final String? sizeFormatted;
final String? status; // PENDING, IN_PROGRESS, COMPLETED, FAILED
final DateTime? createdAt;
final DateTime? completedAt;
final String? createdBy;
final bool? includesDatabase;
final bool? includesFiles;
final bool? includesConfiguration;
final String? filePath;
final String? errorMessage;
const BackupModel({
this.id,
this.name,
this.description,
this.type,
this.sizeBytes,
this.sizeFormatted,
this.status,
this.createdAt,
this.completedAt,
this.createdBy,
this.includesDatabase,
this.includesFiles,
this.includesConfiguration,
this.filePath,
this.errorMessage,
});
factory BackupModel.fromJson(Map<String, dynamic> json) =>
_$BackupModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupModelToJson(this);
@override
List<Object?> get props => [
id,
name,
description,
type,
sizeBytes,
sizeFormatted,
status,
createdAt,
completedAt,
createdBy,
includesDatabase,
includesFiles,
includesConfiguration,
filePath,
errorMessage,
];
}

View File

@@ -0,0 +1,131 @@
/// Repository pour la gestion des sauvegardes
/// Interface avec l'API backend BackupResource
library backup_repository;
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/backup_model.dart';
import '../models/backup_config_model.dart';
abstract class BackupRepository {
Future<List<BackupModel>> getAll();
Future<BackupModel> getById(String id);
Future<BackupModel> create(String name, {String? description});
Future<void> restore(String backupId, {bool createRestorePoint = true});
Future<void> delete(String id);
Future<BackupConfigModel> getConfig();
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config);
Future<BackupModel> createRestorePoint();
}
@LazySingleton(as: BackupRepository)
class BackupRepositoryImpl implements BackupRepository {
final ApiClient _apiClient;
static const String _base = '/api/backups';
BackupRepositoryImpl(this._apiClient);
List<BackupModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<BackupModel>> getAll() async {
final response = await _apiClient.get(_base);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> create(String name, {String? description}) async {
final response = await _apiClient.post(
_base,
data: {
'name': name,
'description': description,
'type': 'MANUAL',
'includeDatabase': true,
'includeFiles': true,
'includeConfiguration': true,
},
);
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<void> restore(String backupId, {bool createRestorePoint = true}) async {
final response = await _apiClient.post(
'$_base/restore',
data: {
'backupId': backupId,
'restoreDatabase': true,
'restoreFiles': true,
'restoreConfiguration': true,
'createRestorePoint': createRestorePoint,
},
);
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<void> delete(String id) async {
final response = await _apiClient.delete('$_base/$id');
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<BackupConfigModel> getConfig() async {
final response = await _apiClient.get('$_base/config');
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config) async {
final response = await _apiClient.put('$_base/config', data: config);
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> createRestorePoint() async {
final response = await _apiClient.post('$_base/restore-point');
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
}

View File

@@ -0,0 +1,166 @@
/// BLoC pour la gestion des sauvegardes
library backup_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:equatable/equatable.dart';
import '../../data/repositories/backup_repository.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
// Events
abstract class BackupEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadBackups extends BackupEvent {}
class CreateBackup extends BackupEvent {
final String name;
final String? description;
CreateBackup(this.name, {this.description});
@override
List<Object?> get props => [name, description];
}
class RestoreBackup extends BackupEvent {
final String backupId;
RestoreBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class DeleteBackup extends BackupEvent {
final String backupId;
DeleteBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class LoadBackupConfig extends BackupEvent {}
class UpdateBackupConfig extends BackupEvent {
final Map<String, dynamic> config;
UpdateBackupConfig(this.config);
@override
List<Object?> get props => [config];
}
// States
abstract class BackupState extends Equatable {
@override
List<Object?> get props => [];
}
class BackupInitial extends BackupState {}
class BackupLoading extends BackupState {}
class BackupsLoaded extends BackupState {
final List<BackupModel> backups;
BackupsLoaded(this.backups);
@override
List<Object?> get props => [backups];
}
class BackupConfigLoaded extends BackupState {
final BackupConfigModel config;
BackupConfigLoaded(this.config);
@override
List<Object?> get props => [config];
}
class BackupSuccess extends BackupState {
final String message;
BackupSuccess(this.message);
@override
List<Object?> get props => [message];
}
class BackupError extends BackupState {
final String error;
BackupError(this.error);
@override
List<Object?> get props => [error];
}
// Bloc
@injectable
class BackupBloc extends Bloc<BackupEvent, BackupState> {
final BackupRepository _repository;
BackupBloc(this._repository) : super(BackupInitial()) {
on<LoadBackups>(_onLoadBackups);
on<CreateBackup>(_onCreateBackup);
on<RestoreBackup>(_onRestoreBackup);
on<DeleteBackup>(_onDeleteBackup);
on<LoadBackupConfig>(_onLoadBackupConfig);
on<UpdateBackupConfig>(_onUpdateBackupConfig);
}
Future<void> _onLoadBackups(LoadBackups event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onCreateBackup(CreateBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.create(event.name, description: event.description);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde créée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onRestoreBackup(RestoreBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.restore(event.backupId);
emit(BackupSuccess('Restauration en cours'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onDeleteBackup(DeleteBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.delete(event.backupId);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde supprimée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onLoadBackupConfig(LoadBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.getConfig();
emit(BackupConfigLoaded(config));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onUpdateBackupConfig(UpdateBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.updateConfig(event.config);
emit(BackupConfigLoaded(config));
emit(BackupSuccess('Configuration mise à jour'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
}

View File

@@ -1,6 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/utils/logger.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
import '../../data/repositories/backup_repository.dart';
import '../bloc/backup_bloc.dart';
/// Page Sauvegarde & Restauration - UnionFlow Mobile
///
@@ -21,6 +30,9 @@ class _BackupPageState extends State<BackupPage>
String _selectedFrequency = 'Quotidien';
String _selectedRetention = '30 jours';
List<BackupModel>? _cachedBackups;
BackupConfigModel? _cachedConfig;
final List<String> _frequencies = ['Horaire', 'Quotidien', 'Hebdomadaire'];
final List<String> _retentions = ['7 jours', '30 jours', '90 jours', '1 an'];
@@ -38,23 +50,56 @@ class _BackupPageState extends State<BackupPage>
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
body: Column(
children: [
_buildHeader(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
return BlocProvider(
create: (_) => sl<BackupBloc>()
..add(LoadBackups())
..add(LoadBackupConfig()),
child: BlocConsumer<BackupBloc, BackupState>(
listener: (context, state) {
if (state is BackupsLoaded) {
_cachedBackups = state.backups;
} else if (state is BackupConfigLoaded) {
_cachedConfig = state.config;
}
if (state is BackupSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: const Color(0xFF00B894),
behavior: SnackBarBehavior.floating,
),
);
} else if (state is BackupError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: const Color(0xFFD63031),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
return Scaffold(
backgroundColor: ColorTokens.background,
body: Column(
children: [
_buildBackupsTab(),
_buildScheduleTab(),
_buildRestoreTab(),
_buildHeader(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildBackupsTab(state),
_buildScheduleTab(),
_buildRestoreTab(),
],
),
),
],
),
),
],
);
},
),
);
}
@@ -138,15 +183,27 @@ class _BackupPageState extends State<BackupPage>
Row(
children: [
Expanded(
child: _buildStatCard('Dernière sauvegarde', '2h', Icons.schedule),
child: _buildStatCard(
'Dernière sauvegarde',
_lastBackupDisplay(),
Icons.schedule,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard('Taille totale', '2.3 GB', Icons.storage),
child: _buildStatCard(
'Taille totale',
_totalSizeDisplay(),
Icons.storage,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard('Statut', 'OK', Icons.check_circle),
child: _buildStatCard(
'Statut',
_statusDisplay(),
Icons.check_circle,
),
),
],
),
@@ -155,6 +212,58 @@ class _BackupPageState extends State<BackupPage>
);
}
String _lastBackupDisplay() {
if (_cachedConfig?.lastBackup != null) {
final d = _cachedConfig!.lastBackup!;
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays} j';
return '${d.day}/${d.month}/${d.year}';
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final d = sorted.first.createdAt;
if (d != null) {
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays} j';
}
}
return '';
}
String _totalSizeDisplay() {
if (_cachedConfig?.totalSizeFormatted != null && _cachedConfig!.totalSizeFormatted!.isNotEmpty) {
return _cachedConfig!.totalSizeFormatted!;
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
int total = 0;
for (final b in _cachedBackups!) {
total += b.sizeBytes ?? 0;
}
if (total >= 1024 * 1024 * 1024) return '${(total / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
if (total >= 1024 * 1024) return '${(total / (1024 * 1024)).toStringAsFixed(1)} MB';
if (total >= 1024) return '${(total / 1024).toStringAsFixed(0)} KB';
return '$total B';
}
return '0 B';
}
String _statusDisplay() {
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final s = sorted.first.status;
if (s == 'COMPLETED') return 'OK';
if (s == 'FAILED') return 'Erreur';
if (s == 'IN_PROGRESS') return 'En cours';
}
return 'OK';
}
/// Carte de statistique
Widget _buildStatCard(String label, String value, IconData icon) {
return Container(
@@ -220,13 +329,15 @@ class _BackupPageState extends State<BackupPage>
}
/// Onglet sauvegardes
Widget _buildBackupsTab() {
Widget _buildBackupsTab(BackupState state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const SizedBox(height: 16),
_buildBackupsList(),
state is BackupLoading
? const Center(child: CircularProgressIndicator())
: _buildBackupsList(state is BackupsLoaded ? state.backups : (_cachedBackups ?? [])),
const SizedBox(height: 80),
],
),
@@ -234,12 +345,14 @@ class _BackupPageState extends State<BackupPage>
}
/// Liste des sauvegardes
Widget _buildBackupsList() {
final backups = [
{'name': 'Sauvegarde automatique', 'date': '15/12/2024 02:00', 'size': '2.3 GB', 'type': 'Auto'},
{'name': 'Sauvegarde manuelle', 'date': '14/12/2024 14:30', 'size': '2.1 GB', 'type': 'Manuel'},
{'name': 'Sauvegarde automatique', 'date': '14/12/2024 02:00', 'size': '2.2 GB', 'type': 'Auto'},
];
Widget _buildBackupsList(List<dynamic> backupsData) {
final backups = backupsData.map((backup) => {
'id': backup.id?.toString() ?? '',
'name': backup.name ?? 'Sans nom',
'date': backup.createdAt?.toString() ?? '',
'size': backup.sizeFormatted ?? '0 B',
'type': backup.type ?? 'Manual',
}).toList();
return Container(
padding: const EdgeInsets.all(16),
@@ -279,7 +392,7 @@ class _BackupPageState extends State<BackupPage>
}
/// Élément de sauvegarde
Widget _buildBackupItem(Map<String, String> backup) {
Widget _buildBackupItem(Map<String, dynamic> backup) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
@@ -554,11 +667,103 @@ class _BackupPageState extends State<BackupPage>
}
// Méthodes d'action
void _createBackupNow() => _showSuccessSnackBar('Sauvegarde créée avec succès');
void _handleBackupAction(Map<String, String> backup, String action) => _showSuccessSnackBar('Action "$action" exécutée');
void _restoreFromFile() => _showSuccessSnackBar('Sélection de fichier de restauration');
void _selectiveRestore() => _showSuccessSnackBar('Mode de restauration sélective');
void _createRestorePoint() => _showSuccessSnackBar('Point de restauration créé');
void _createBackupNow() {
context.read<BackupBloc>().add(CreateBackup('Sauvegarde manuelle', description: 'Créée depuis l\'application mobile'));
}
void _handleBackupAction(Map<String, dynamic> backup, String action) {
final backupId = backup['id'];
if (backupId == null) return;
if (action == 'restore') {
context.read<BackupBloc>().add(RestoreBackup(backupId));
} else if (action == 'delete') {
context.read<BackupBloc>().add(DeleteBackup(backupId));
} else if (action == 'download') {
_downloadBackup(backupId);
} else {
_showSuccessSnackBar('Action "$action" exécutée');
}
}
Future<void> _downloadBackup(String backupId) async {
try {
final repo = sl<BackupRepository>();
final b = await repo.getById(backupId);
if (b.filePath != null && b.filePath!.isNotEmpty) {
try {
await Share.share(
b.filePath!,
subject: 'Sauvegarde ${b.name ?? backupId}',
);
_showSuccessSnackBar('Partage du lien de téléchargement');
} catch (e, st) {
AppLogger.error('BackupPage: partage échoué', error: e, stackTrace: st);
_showSuccessSnackBar('Téléchargement: configurez l\'URL de téléchargement côté backend');
}
} else {
_showSuccessSnackBar('Téléchargement: l\'API ne fournit pas encore de lien (filePath).');
}
} catch (e, st) {
AppLogger.error('BackupPage: téléchargement échoué', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de récupérer la sauvegarde.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _restoreFromFile() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.single.path;
if (path != null && path.isNotEmpty) {
_showSuccessSnackBar('Fichier sélectionné. Restauration depuis fichier à brancher côté API.');
} else {
_showSuccessSnackBar('Restauration depuis fichier à brancher côté API.');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration depuis fichier', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection de fichier impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _selectiveRestore() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: true,
);
if (result == null || result.files.isEmpty) {
_showSuccessSnackBar('Restauration sélective: sélectionnez un ou plusieurs fichiers.');
return;
}
final paths = result.files.map((f) => f.path).whereType<String>().toList();
if (paths.isNotEmpty) {
_showSuccessSnackBar('Restauration sélective: ${paths.length} fichier(s) (API à brancher).');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration sélective', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
void _createRestorePoint() {
context.read<BackupBloc>().add(CreateBackup('Point de restauration', description: 'Point de restauration'));
}
void _showSuccessSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(