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:
@@ -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