feat(sprint-13.C mobile 2026-04-25): feature reporting (rapports trimestriels Contrôleur Interne)
Mobile mirror du backend P2-NEW-3. DRY strict — Clean Architecture identique aux autres features (compliance, devise). Domain - StatutRapport enum (draft, signe, archive, inconnu) + extension StatutRapportX (code/libelle/severite/fromCode) - RapportTrimestriel entity (Equatable) + helpers libellePeriode, scoreSeverite (>=80 success, >=60 warning, <60 danger) Data - RapportTrimestrielModel.fromJson (parsing tolérant null + dates ISO) - ReportingRemoteDataSourceImpl (Dio) : lister/generer/signer/archiver/telechargerPdf - ReportingRepositoryImpl @Injectable Presentation - ReportingBloc : Load/Generer/Signer/Archiver events, 4 states (Initial/Loading/Loaded/Error) - RapportsTrimestrielsPage : Material 3 ListView avec cards (period, score coloré, tag statut, hash tronqué) + EmptyView + ErrorView Tests (10/10 verts) - StatutRapportX × 2 (fromCode parse/null/inconnu, libelle/severite/code) - RapportTrimestriel × 5 (libellePeriode, estDraft/estSigne/estArchive, scoreSeverite × 3) - RapportTrimestrielModel.fromJson × 3 (complet, fallbacks, date invalide) Note : viewer PDF interne reporté à un sprint dédié (intégration pdfx + permission storage Android). Téléchargement bytes via API exposé dans datasource pour usage futur.
This commit is contained in:
129
lib/features/reporting/presentation/bloc/reporting_bloc.dart
Normal file
129
lib/features/reporting/presentation/bloc/reporting_bloc.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../domain/entities/rapport_trimestriel.dart';
|
||||
import '../../domain/repositories/reporting_repository.dart';
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────
|
||||
abstract class ReportingEvent extends Equatable {
|
||||
const ReportingEvent();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadRapports extends ReportingEvent {
|
||||
final int? annee;
|
||||
const LoadRapports({this.annee});
|
||||
@override
|
||||
List<Object?> get props => [annee];
|
||||
}
|
||||
|
||||
class GenererRapport extends ReportingEvent {
|
||||
final int annee;
|
||||
final int trimestre;
|
||||
const GenererRapport({required this.annee, required this.trimestre});
|
||||
@override
|
||||
List<Object?> get props => [annee, trimestre];
|
||||
}
|
||||
|
||||
class SignerRapport extends ReportingEvent {
|
||||
final String id;
|
||||
final String signataireId;
|
||||
const SignerRapport({required this.id, required this.signataireId});
|
||||
@override
|
||||
List<Object?> get props => [id, signataireId];
|
||||
}
|
||||
|
||||
class ArchiverRapport extends ReportingEvent {
|
||||
final String id;
|
||||
const ArchiverRapport(this.id);
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
// ── States ───────────────────────────────────────────────────────────
|
||||
abstract class ReportingState extends Equatable {
|
||||
const ReportingState();
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ReportingInitial extends ReportingState {
|
||||
const ReportingInitial();
|
||||
}
|
||||
|
||||
class ReportingLoading extends ReportingState {
|
||||
const ReportingLoading();
|
||||
}
|
||||
|
||||
class ReportingLoaded extends ReportingState {
|
||||
final List<RapportTrimestriel> rapports;
|
||||
final int annee;
|
||||
const ReportingLoaded({required this.rapports, required this.annee});
|
||||
@override
|
||||
List<Object?> get props => [rapports, annee];
|
||||
}
|
||||
|
||||
class ReportingError extends ReportingState {
|
||||
final String message;
|
||||
const ReportingError(this.message);
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
// ── Bloc ─────────────────────────────────────────────────────────────
|
||||
@injectable
|
||||
class ReportingBloc extends Bloc<ReportingEvent, ReportingState> {
|
||||
final ReportingRepository repository;
|
||||
|
||||
ReportingBloc(this.repository) : super(const ReportingInitial()) {
|
||||
on<LoadRapports>(_onLoad);
|
||||
on<GenererRapport>(_onGenerer);
|
||||
on<SignerRapport>(_onSigner);
|
||||
on<ArchiverRapport>(_onArchiver);
|
||||
}
|
||||
|
||||
Future<void> _onLoad(LoadRapports event, Emitter<ReportingState> emit) async {
|
||||
emit(const ReportingLoading());
|
||||
try {
|
||||
final annee = event.annee ?? DateTime.now().year;
|
||||
final rapports = await repository.lister(annee: annee);
|
||||
emit(ReportingLoaded(rapports: rapports, annee: annee));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('ReportingBloc: load', error: e, stackTrace: st);
|
||||
emit(ReportingError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onGenerer(GenererRapport event, Emitter<ReportingState> emit) async {
|
||||
final current = state;
|
||||
try {
|
||||
await repository.generer(annee: event.annee, trimestre: event.trimestre);
|
||||
// Reload list
|
||||
add(LoadRapports(annee: current is ReportingLoaded ? current.annee : event.annee));
|
||||
} catch (e) {
|
||||
emit(ReportingError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSigner(SignerRapport event, Emitter<ReportingState> emit) async {
|
||||
final current = state;
|
||||
try {
|
||||
await repository.signer(event.id, event.signataireId);
|
||||
add(LoadRapports(annee: current is ReportingLoaded ? current.annee : null));
|
||||
} catch (e) {
|
||||
emit(ReportingError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onArchiver(ArchiverRapport event, Emitter<ReportingState> emit) async {
|
||||
final current = state;
|
||||
try {
|
||||
await repository.archiver(event.id);
|
||||
add(LoadRapports(annee: current is ReportingLoaded ? current.annee : null));
|
||||
} catch (e) {
|
||||
emit(ReportingError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../domain/entities/rapport_trimestriel.dart';
|
||||
import '../bloc/reporting_bloc.dart';
|
||||
|
||||
/// Liste + actions sur les rapports trimestriels Contrôleur Interne (Sprint 13.C).
|
||||
class RapportsTrimestrielsPage extends StatelessWidget {
|
||||
const RapportsTrimestrielsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => getIt<ReportingBloc>()..add(const LoadRapports()),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Rapports trimestriels'),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ctx.read<ReportingBloc>().add(const LoadRapports()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<ReportingBloc, ReportingState>(
|
||||
builder: (context, state) {
|
||||
if (state is ReportingLoading || state is ReportingInitial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is ReportingError) {
|
||||
return _ErrorView(message: state.message);
|
||||
}
|
||||
if (state is ReportingLoaded) {
|
||||
if (state.rapports.isEmpty) {
|
||||
return _EmptyView(annee: state.annee);
|
||||
}
|
||||
return _ListView(rapports: state.rapports);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListView extends StatelessWidget {
|
||||
final List<RapportTrimestriel> rapports;
|
||||
const _ListView({required this.rapports});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: rapports.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) => _RapportCard(rapport: rapports[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RapportCard extends StatelessWidget {
|
||||
final RapportTrimestriel rapport;
|
||||
const _RapportCard({required this.rapport});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scoreColor = _color(rapport.scoreSeverite);
|
||||
final statutColor = _color(rapport.statut.severite);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
rapport.libellePeriode,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
_Tag(label: rapport.statut.libelle, color: statutColor),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: scoreColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Score : ${rapport.scoreConformite}/100',
|
||||
style: TextStyle(color: scoreColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (rapport.dateGeneration != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Généré le ${_formatDate(rapport.dateGeneration!)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (rapport.hashSha256 != null && rapport.hashSha256!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Hash : ${rapport.hashSha256!.substring(0, 16)}…',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime d) =>
|
||||
'${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
|
||||
|
||||
Color _color(String s) => switch (s) {
|
||||
'success' => Colors.green,
|
||||
'warning' => Colors.orange,
|
||||
'danger' => Colors.red,
|
||||
'info' => Colors.blue,
|
||||
_ => Colors.grey,
|
||||
};
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
const _Tag({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyView extends StatelessWidget {
|
||||
final int annee;
|
||||
const _EmptyView({required this.annee});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.description_outlined, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun rapport pour $annee',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Les rapports trimestriels sont générés automatiquement\nle 1er du mois suivant chaque trimestre.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorView({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Impossible de charger les rapports',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user