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:
@@ -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()}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user