feat(sprint-9 mobile 2026-04-25): feature compliance dashboard + Devise enum + selector + tests

Apporte aux compliance officers et controleurs internes l'accès mobile au tableau de bord
de conformité backend (P1-NEW-7). Et prépare la diaspora avec Devise enum + sélecteur
préférence persisté.

Feature Compliance (Clean Architecture)
- Domain : ComplianceSnapshot + ConformiteIndicateur (Equatable), helpers scoreSeverite + hasAlertesCritiques
- Data : ComplianceSnapshotModel.fromJson (parsing tolerant aux nullables), ComplianceRemoteDataSourceImpl Dio (GET /api/compliance/dashboard), ComplianceRepositoryImpl @Injectable
- Presentation : ComplianceBloc (Load/Refresh events, Initial/Loading/Loaded/Error states), ConformiteDashboardPage (Material 3, ScoreCard 0-100 colorée, 9 IndicateurTile, AlertesCard rouge si critiques)

Feature Devise
- Devise enum (10 valeurs miroirs backend, code/libelle/zone)
- fromCode tolérant casse + null/vide → XOF
- estInternationale pour AML
- DeviseSelector widget DropdownButtonFormField + readPreferred/writePreferred via FlutterSecureStorage (clé unionflow.devise.preferee)

Tests (17/17 verts)
- ComplianceSnapshot : 7 tests (scoreSeverite × 3, hasAlertesCritiques × 4)
- ComplianceSnapshotModel.fromJson : 4 tests (complet, fallbacks, string→double, indicateur invalide)
- Devise enum : 6 tests (reference, fromCode parse, fromCode null/inconnu, estInternationale × 2, intégrité valeurs)

Note : feature reporting trimestriel mobile (PDF viewer + bloc liste) reportée à un sprint
dédié — nécessite intégration pdf viewer + cache local non triviale.
This commit is contained in:
dahoud
2026-04-25 11:11:03 +00:00
parent 8356ccc0b0
commit 8c1a254e80
12 changed files with 808 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/utils/logger.dart';
import '../models/compliance_snapshot_model.dart';
abstract class ComplianceRemoteDataSource {
/// GET /api/compliance/dashboard — snapshot pour l'organisation active.
Future<ComplianceSnapshotModel> getSnapshotCurrent();
}
@Injectable(as: ComplianceRemoteDataSource)
class ComplianceRemoteDataSourceImpl implements ComplianceRemoteDataSource {
final ApiClient apiClient;
ComplianceRemoteDataSourceImpl(this.apiClient);
@override
Future<ComplianceSnapshotModel> getSnapshotCurrent() async {
try {
final response = await apiClient.get('/api/compliance/dashboard');
if (response.statusCode == 200 && response.data is Map<String, dynamic>) {
return ComplianceSnapshotModel.fromJson(
response.data as Map<String, dynamic>,
);
}
throw ServerException('Compliance snapshot HTTP ${response.statusCode}');
} on DioException catch (e) {
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent', error: e);
rethrow;
} catch (e, st) {
AppLogger.error('ComplianceRemoteDataSource: getSnapshotCurrent',
error: e, stackTrace: st);
rethrow;
}
}
}

View File

@@ -0,0 +1,53 @@
import '../../domain/entities/compliance_snapshot.dart';
class ComplianceSnapshotModel extends ComplianceSnapshot {
const ComplianceSnapshotModel({
required super.organisationId,
required super.organisationNom,
required super.referentielComptable,
required super.complianceOfficerDesigne,
required super.agAnnuelle,
required super.rapportAirms,
required super.dirigeantsAvecCmu,
required super.tauxKycAJourPct,
required super.tauxFormationLbcFtPct,
required super.commissaireAuxComptes,
required super.fomusCi,
required super.couvertureUboPct,
required super.scoreGlobal,
});
factory ComplianceSnapshotModel.fromJson(Map<String, dynamic> json) {
return ComplianceSnapshotModel(
organisationId: json['organisationId']?.toString() ?? '',
organisationNom: json['organisationNom']?.toString() ?? '',
referentielComptable: json['referentielComptable']?.toString() ?? 'SYSCOHADA',
complianceOfficerDesigne: json['complianceOfficerDesigne'] as bool? ?? false,
agAnnuelle: _indicateur(json['agAnnuelle']),
rapportAirms: _indicateur(json['rapportAirms']),
dirigeantsAvecCmu: (json['dirigeantsAvecCmu'] as num?)?.toInt() ?? 0,
tauxKycAJourPct: _toDouble(json['tauxKycAJourPct']),
tauxFormationLbcFtPct: _toDouble(json['tauxFormationLbcFtPct']),
commissaireAuxComptes: _indicateur(json['commissaireAuxComptes']),
fomusCi: _indicateur(json['fomusCi']),
couvertureUboPct: _toDouble(json['couvertureUboPct']),
scoreGlobal: (json['scoreGlobal'] as num?)?.toInt() ?? 0,
);
}
static ConformiteIndicateur _indicateur(dynamic raw) {
if (raw is Map<String, dynamic>) {
return ConformiteIndicateur(
statut: raw['statut']?.toString() ?? 'EN_VEILLE',
message: raw['message']?.toString() ?? '',
);
}
return const ConformiteIndicateur(statut: 'EN_VEILLE', message: '');
}
static double _toDouble(dynamic raw) {
if (raw is num) return raw.toDouble();
if (raw is String) return double.tryParse(raw) ?? 0.0;
return 0.0;
}
}

View File

@@ -0,0 +1,14 @@
import 'package:injectable/injectable.dart';
import '../../domain/entities/compliance_snapshot.dart';
import '../../domain/repositories/compliance_repository.dart';
import '../datasources/compliance_remote_datasource.dart';
@Injectable(as: ComplianceRepository)
class ComplianceRepositoryImpl implements ComplianceRepository {
final ComplianceRemoteDataSource remote;
ComplianceRepositoryImpl(this.remote);
@override
Future<ComplianceSnapshot> getSnapshotCurrent() => remote.getSnapshotCurrent();
}

View File

@@ -0,0 +1,77 @@
import 'package:equatable/equatable.dart';
/// Indicateur de conformité avec statut + message explicatif.
class ConformiteIndicateur extends Equatable {
final String statut; // OK, EN_ATTENTE, RETARD, OPTIONNEL, OBLIGATOIRE, EN_VEILLE
final String message;
const ConformiteIndicateur({required this.statut, required this.message});
@override
List<Object?> get props => [statut, message];
}
/// Snapshot du tableau de bord de conformité (miroir mobile du record backend).
///
/// Couvre AG annuelle, rapports AIRMS, CMU dirigeants, KYC, formation LBC/FT, UBO,
/// commissaire aux comptes, FOMUS-CI, et le score global agrégé (0-100).
class ComplianceSnapshot extends Equatable {
final String organisationId;
final String organisationNom;
final String referentielComptable;
final bool complianceOfficerDesigne;
final ConformiteIndicateur agAnnuelle;
final ConformiteIndicateur rapportAirms;
final int dirigeantsAvecCmu;
final double tauxKycAJourPct;
final double tauxFormationLbcFtPct;
final ConformiteIndicateur commissaireAuxComptes;
final ConformiteIndicateur fomusCi;
final double couvertureUboPct;
final int scoreGlobal;
const ComplianceSnapshot({
required this.organisationId,
required this.organisationNom,
required this.referentielComptable,
required this.complianceOfficerDesigne,
required this.agAnnuelle,
required this.rapportAirms,
required this.dirigeantsAvecCmu,
required this.tauxKycAJourPct,
required this.tauxFormationLbcFtPct,
required this.commissaireAuxComptes,
required this.fomusCi,
required this.couvertureUboPct,
required this.scoreGlobal,
});
/// Couleur indicative selon le score (Material 3 semantic).
String get scoreSeverite {
if (scoreGlobal >= 80) return 'success';
if (scoreGlobal >= 60) return 'warning';
return 'danger';
}
bool get hasAlertesCritiques =>
!complianceOfficerDesigne ||
agAnnuelle.statut == 'RETARD' ||
scoreGlobal < 60;
@override
List<Object?> get props => [
organisationId,
organisationNom,
referentielComptable,
complianceOfficerDesigne,
agAnnuelle,
rapportAirms,
dirigeantsAvecCmu,
tauxKycAJourPct,
tauxFormationLbcFtPct,
commissaireAuxComptes,
fomusCi,
couvertureUboPct,
scoreGlobal,
];
}

View File

@@ -0,0 +1,5 @@
import '../entities/compliance_snapshot.dart';
abstract class ComplianceRepository {
Future<ComplianceSnapshot> getSnapshotCurrent();
}

View File

@@ -0,0 +1,71 @@
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/compliance_snapshot.dart';
import '../../domain/repositories/compliance_repository.dart';
// Events
abstract class ComplianceEvent extends Equatable {
const ComplianceEvent();
@override
List<Object?> get props => [];
}
class LoadComplianceSnapshot extends ComplianceEvent {
const LoadComplianceSnapshot();
}
class RefreshComplianceSnapshot extends ComplianceEvent {
const RefreshComplianceSnapshot();
}
// States
abstract class ComplianceState extends Equatable {
const ComplianceState();
@override
List<Object?> get props => [];
}
class ComplianceInitial extends ComplianceState {
const ComplianceInitial();
}
class ComplianceLoading extends ComplianceState {
const ComplianceLoading();
}
class ComplianceLoaded extends ComplianceState {
final ComplianceSnapshot snapshot;
const ComplianceLoaded(this.snapshot);
@override
List<Object?> get props => [snapshot];
}
class ComplianceError extends ComplianceState {
final String message;
const ComplianceError(this.message);
@override
List<Object?> get props => [message];
}
@injectable
class ComplianceBloc extends Bloc<ComplianceEvent, ComplianceState> {
final ComplianceRepository repository;
ComplianceBloc(this.repository) : super(const ComplianceInitial()) {
on<LoadComplianceSnapshot>(_onLoad);
on<RefreshComplianceSnapshot>(_onLoad);
}
Future<void> _onLoad(ComplianceEvent event, Emitter<ComplianceState> emit) async {
emit(const ComplianceLoading());
try {
final s = await repository.getSnapshotCurrent();
emit(ComplianceLoaded(s));
} catch (e, st) {
AppLogger.error('ComplianceBloc: load failed', error: e, stackTrace: st);
emit(ComplianceError(e.toString()));
}
}
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../domain/entities/compliance_snapshot.dart';
import '../bloc/compliance_bloc.dart';
/// Page tableau de bord de conformité — affiche le snapshot agrégé pour
/// l'organisation active du membre connecté.
///
/// Cible : Compliance Officer, Contrôleur Interne, Président, Trésorier, Admin Org.
class ConformiteDashboardPage extends StatelessWidget {
const ConformiteDashboardPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => getIt<ComplianceBloc>()..add(const LoadComplianceSnapshot()),
child: Scaffold(
appBar: AppBar(
title: const Text('Conformité'),
actions: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ctx.read<ComplianceBloc>().add(const RefreshComplianceSnapshot()),
),
),
],
),
body: BlocBuilder<ComplianceBloc, ComplianceState>(
builder: (context, state) {
if (state is ComplianceLoading || state is ComplianceInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is ComplianceError) {
return _Error(message: state.message);
}
if (state is ComplianceLoaded) {
return _Content(snapshot: state.snapshot);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _Content extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _Content({required this.snapshot});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ScoreCard(snapshot: snapshot),
const SizedBox(height: 16),
_IndicateurTile(
label: 'Compliance Officer',
value: snapshot.complianceOfficerDesigne ? 'Désigné' : 'ABSENT',
severite: snapshot.complianceOfficerDesigne ? 'success' : 'danger',
),
_IndicateurTile(
label: 'AG annuelle',
value: snapshot.agAnnuelle.statut,
sous: snapshot.agAnnuelle.message,
severite: _statutSeverite(snapshot.agAnnuelle.statut),
),
_IndicateurTile(
label: 'Rapport AIRMS',
value: snapshot.rapportAirms.statut,
severite: _statutSeverite(snapshot.rapportAirms.statut),
),
_IndicateurTile(
label: 'Dirigeants enrôlés CMU',
value: snapshot.dirigeantsAvecCmu.toString(),
severite: snapshot.dirigeantsAvecCmu >= 3 ? 'success' : 'warning',
),
_IndicateurTile(
label: 'Taux KYC à jour',
value: '${snapshot.tauxKycAJourPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Taux formation LBC/FT',
value: '${snapshot.tauxFormationLbcFtPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Couverture UBO',
value: '${snapshot.couvertureUboPct.toStringAsFixed(1)} %',
),
_IndicateurTile(
label: 'Commissaire aux comptes',
value: snapshot.commissaireAuxComptes.statut,
sous: snapshot.commissaireAuxComptes.message,
),
_IndicateurTile(
label: 'FOMUS-CI',
value: snapshot.fomusCi.statut,
sous: snapshot.fomusCi.message,
),
if (snapshot.hasAlertesCritiques) ...[
const SizedBox(height: 16),
_AlertesCard(snapshot: snapshot),
],
],
),
);
}
String _statutSeverite(String s) {
if (s == 'OK') return 'success';
if (s == 'RETARD') return 'danger';
return 'warning';
}
}
class _ScoreCard extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _ScoreCard({required this.snapshot});
@override
Widget build(BuildContext context) {
final color = _color(snapshot.scoreSeverite);
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(snapshot.organisationNom,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center),
const SizedBox(height: 4),
Text('Référentiel : ${snapshot.referentielComptable}',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
Text('Score conformité',
style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(
'${snapshot.scoreGlobal} / 100',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}
Color _color(String s) => switch (s) {
'success' => Colors.green,
'warning' => Colors.orange,
'danger' => Colors.red,
_ => Colors.grey,
};
}
class _IndicateurTile extends StatelessWidget {
final String label;
final String value;
final String? sous;
final String? severite;
const _IndicateurTile({
required this.label,
required this.value,
this.sous,
this.severite,
});
@override
Widget build(BuildContext context) {
final color = severite != null ? _color(severite!) : Colors.grey;
return Card(
child: ListTile(
title: Text(label),
subtitle: sous != null ? Text(sous!, maxLines: 2, overflow: TextOverflow.ellipsis) : null,
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Text(
value,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
),
),
);
}
Color _color(String s) => switch (s) {
'success' => Colors.green,
'warning' => Colors.orange,
'danger' => Colors.red,
_ => Colors.grey,
};
}
class _AlertesCard extends StatelessWidget {
final ComplianceSnapshot snapshot;
const _AlertesCard({required this.snapshot});
@override
Widget build(BuildContext context) {
final alertes = <String>[];
if (!snapshot.complianceOfficerDesigne) {
alertes.add('Compliance Officer non désigné (Instr. BCEAO 001-03-2025)');
}
if (snapshot.agAnnuelle.statut == 'RETARD') {
alertes.add('AG annuelle en retard (échéance 30/06)');
}
if (snapshot.scoreGlobal < 60) {
alertes.add('Score conformité < 60 % — risque inspection');
}
return Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.warning, color: Colors.red),
const SizedBox(width: 8),
Text('Alertes critiques',
style: Theme.of(context).textTheme.titleMedium),
]),
const SizedBox(height: 8),
for (final a in alertes)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('$a', style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}
class _Error extends StatelessWidget {
final String message;
const _Error({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 le tableau de bord',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(message,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center),
],
),
),
);
}
}