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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../entities/compliance_snapshot.dart';
|
||||
|
||||
abstract class ComplianceRepository {
|
||||
Future<ComplianceSnapshot> getSnapshotCurrent();
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/features/devise/domain/entities/devise.dart
Normal file
41
lib/features/devise/domain/entities/devise.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
/// Devises supportées par UnionFlow (miroir mobile de l'enum backend).
|
||||
///
|
||||
/// Les codes ISO-4217 servent aussi d'identifiant pour l'API.
|
||||
enum Devise {
|
||||
xof('XOF', 'Franc CFA Ouest', 'UEMOA'),
|
||||
xaf('XAF', 'Franc CFA Centrale', 'CEMAC'),
|
||||
eur('EUR', 'Euro', 'EUROPE'),
|
||||
usd('USD', 'Dollar US', 'AMERIQUE'),
|
||||
gbp('GBP', 'Livre Sterling', 'EUROPE'),
|
||||
cad('CAD', 'Dollar Canadien', 'AMERIQUE'),
|
||||
chf('CHF', 'Franc Suisse', 'EUROPE'),
|
||||
ghs('GHS', 'Cédi Ghanéen', 'CEDEAO'),
|
||||
ngn('NGN', 'Naira Nigérian', 'CEDEAO'),
|
||||
mad('MAD', 'Dirham Marocain', 'MAGHREB');
|
||||
|
||||
final String code;
|
||||
final String libelle;
|
||||
final String zone;
|
||||
const Devise(this.code, this.libelle, this.zone);
|
||||
|
||||
/// Devise de référence UnionFlow.
|
||||
static Devise reference() => Devise.xof;
|
||||
|
||||
/// Parse un code ISO-4217 en {@link Devise}, fallback sur XOF.
|
||||
static Devise fromCode(String? code) {
|
||||
if (code == null || code.isEmpty) return reference();
|
||||
return Devise.values.firstWhere(
|
||||
(d) => d.code.toUpperCase() == code.toUpperCase(),
|
||||
orElse: () => reference(),
|
||||
);
|
||||
}
|
||||
|
||||
/// True pour les devises internationales (déclenchent AML international).
|
||||
bool get estInternationale => const {
|
||||
Devise.eur,
|
||||
Devise.usd,
|
||||
Devise.gbp,
|
||||
Devise.cad,
|
||||
Devise.chf,
|
||||
}.contains(this);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../domain/entities/devise.dart';
|
||||
|
||||
const _kDevisePrefereeKey = 'unionflow.devise.preferee';
|
||||
|
||||
/// Widget de sélection de la devise préférée (persistée en secure storage).
|
||||
///
|
||||
/// Utilisé sur la page Profil/Paramètres. Notifie via [onChanged] avec la
|
||||
/// nouvelle devise quand l'utilisateur fait un choix.
|
||||
class DeviseSelector extends StatefulWidget {
|
||||
final Devise? initial;
|
||||
final ValueChanged<Devise>? onChanged;
|
||||
|
||||
const DeviseSelector({super.key, this.initial, this.onChanged});
|
||||
|
||||
/// Lit la devise préférée du secure storage. Fallback sur XOF.
|
||||
static Future<Devise> readPreferred(FlutterSecureStorage storage) async {
|
||||
try {
|
||||
final v = await storage.read(key: _kDevisePrefereeKey);
|
||||
return Devise.fromCode(v);
|
||||
} catch (_) {
|
||||
return Devise.reference();
|
||||
}
|
||||
}
|
||||
|
||||
/// Persiste la devise préférée.
|
||||
static Future<void> writePreferred(
|
||||
FlutterSecureStorage storage, Devise devise) async {
|
||||
await storage.write(key: _kDevisePrefereeKey, value: devise.code);
|
||||
}
|
||||
|
||||
@override
|
||||
State<DeviseSelector> createState() => _DeviseSelectorState();
|
||||
}
|
||||
|
||||
class _DeviseSelectorState extends State<DeviseSelector> {
|
||||
late Devise _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = widget.initial ?? Devise.reference();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<Devise>(
|
||||
value: _selected,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Devise préférée',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.account_balance_wallet_outlined),
|
||||
),
|
||||
items: Devise.values
|
||||
.map(
|
||||
(d) => DropdownMenuItem<Devise>(
|
||||
value: d,
|
||||
child: Text('${d.code} — ${d.libelle}'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (d) {
|
||||
if (d == null) return;
|
||||
setState(() => _selected = d);
|
||||
widget.onChanged?.call(d);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:unionflow_mobile_apps/features/compliance/data/models/compliance_snapshot_model.dart';
|
||||
|
||||
void main() {
|
||||
group('ComplianceSnapshotModel.fromJson', () {
|
||||
test('parsing complet', () {
|
||||
final json = {
|
||||
'organisationId': 'org-uuid',
|
||||
'organisationNom': 'Mutuelle Test',
|
||||
'referentielComptable': 'SYCEBNL',
|
||||
'complianceOfficerDesigne': true,
|
||||
'agAnnuelle': {'statut': 'OK', 'message': 'AG tenue'},
|
||||
'rapportAirms': {'statut': 'EN_ATTENTE', 'message': ''},
|
||||
'dirigeantsAvecCmu': 3,
|
||||
'tauxKycAJourPct': 85.5,
|
||||
'tauxFormationLbcFtPct': 70.0,
|
||||
'commissaireAuxComptes': {'statut': 'OBLIGATOIRE', 'message': 'SFD'},
|
||||
'fomusCi': {'statut': 'EN_VEILLE', 'message': ''},
|
||||
'couvertureUboPct': 60.0,
|
||||
'scoreGlobal': 82,
|
||||
};
|
||||
final m = ComplianceSnapshotModel.fromJson(json);
|
||||
expect(m.organisationNom, 'Mutuelle Test');
|
||||
expect(m.referentielComptable, 'SYCEBNL');
|
||||
expect(m.complianceOfficerDesigne, isTrue);
|
||||
expect(m.agAnnuelle.statut, 'OK');
|
||||
expect(m.dirigeantsAvecCmu, 3);
|
||||
expect(m.tauxKycAJourPct, 85.5);
|
||||
expect(m.scoreGlobal, 82);
|
||||
});
|
||||
|
||||
test('valeurs manquantes → fallbacks raisonnables', () {
|
||||
final m = ComplianceSnapshotModel.fromJson(<String, dynamic>{});
|
||||
expect(m.organisationId, '');
|
||||
expect(m.referentielComptable, 'SYSCOHADA');
|
||||
expect(m.complianceOfficerDesigne, isFalse);
|
||||
expect(m.agAnnuelle.statut, 'EN_VEILLE');
|
||||
expect(m.dirigeantsAvecCmu, 0);
|
||||
expect(m.tauxKycAJourPct, 0.0);
|
||||
expect(m.scoreGlobal, 0);
|
||||
});
|
||||
|
||||
test('taux passé en string → converti en double', () {
|
||||
final m = ComplianceSnapshotModel.fromJson({
|
||||
'tauxKycAJourPct': '85.5',
|
||||
'couvertureUboPct': '60',
|
||||
});
|
||||
expect(m.tauxKycAJourPct, 85.5);
|
||||
expect(m.couvertureUboPct, 60.0);
|
||||
});
|
||||
|
||||
test('indicateur non-Map → fallback EN_VEILLE', () {
|
||||
final m = ComplianceSnapshotModel.fromJson({'agAnnuelle': 'invalid'});
|
||||
expect(m.agAnnuelle.statut, 'EN_VEILLE');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:unionflow_mobile_apps/features/compliance/domain/entities/compliance_snapshot.dart';
|
||||
|
||||
ComplianceSnapshot _build({
|
||||
int score = 85,
|
||||
bool officer = true,
|
||||
String agStatut = 'OK',
|
||||
}) {
|
||||
return ComplianceSnapshot(
|
||||
organisationId: 'org-1',
|
||||
organisationNom: 'Test',
|
||||
referentielComptable: 'SYSCOHADA',
|
||||
complianceOfficerDesigne: officer,
|
||||
agAnnuelle: ConformiteIndicateur(statut: agStatut, message: ''),
|
||||
rapportAirms: const ConformiteIndicateur(statut: 'OK', message: ''),
|
||||
dirigeantsAvecCmu: 3,
|
||||
tauxKycAJourPct: 80,
|
||||
tauxFormationLbcFtPct: 70,
|
||||
commissaireAuxComptes: const ConformiteIndicateur(statut: 'OPTIONNEL', message: ''),
|
||||
fomusCi: const ConformiteIndicateur(statut: 'EN_VEILLE', message: ''),
|
||||
couvertureUboPct: 60,
|
||||
scoreGlobal: score,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ComplianceSnapshot', () {
|
||||
test('scoreSeverite — score >=80 → success', () {
|
||||
expect(_build(score: 85).scoreSeverite, 'success');
|
||||
});
|
||||
|
||||
test('scoreSeverite — 60..79 → warning', () {
|
||||
expect(_build(score: 70).scoreSeverite, 'warning');
|
||||
});
|
||||
|
||||
test('scoreSeverite — <60 → danger', () {
|
||||
expect(_build(score: 50).scoreSeverite, 'danger');
|
||||
});
|
||||
|
||||
test('hasAlertesCritiques — score 85 + officer + AG OK → false', () {
|
||||
expect(_build().hasAlertesCritiques, isFalse);
|
||||
});
|
||||
|
||||
test('hasAlertesCritiques — officer absent → true', () {
|
||||
expect(_build(officer: false).hasAlertesCritiques, isTrue);
|
||||
});
|
||||
|
||||
test('hasAlertesCritiques — AG en retard → true', () {
|
||||
expect(_build(agStatut: 'RETARD').hasAlertesCritiques, isTrue);
|
||||
});
|
||||
|
||||
test('hasAlertesCritiques — score <60 → true', () {
|
||||
expect(_build(score: 50).hasAlertesCritiques, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
46
test/features/devise/devise_test.dart
Normal file
46
test/features/devise/devise_test.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:unionflow_mobile_apps/features/devise/domain/entities/devise.dart';
|
||||
|
||||
void main() {
|
||||
group('Devise enum', () {
|
||||
test('reference = XOF', () {
|
||||
expect(Devise.reference(), Devise.xof);
|
||||
});
|
||||
|
||||
test('fromCode parse correct', () {
|
||||
expect(Devise.fromCode('EUR'), Devise.eur);
|
||||
expect(Devise.fromCode('usd'), Devise.usd);
|
||||
expect(Devise.fromCode('GBP'), Devise.gbp);
|
||||
});
|
||||
|
||||
test('fromCode null/vide/inconnu → XOF (référence)', () {
|
||||
expect(Devise.fromCode(null), Devise.xof);
|
||||
expect(Devise.fromCode(''), Devise.xof);
|
||||
expect(Devise.fromCode('XYZ'), Devise.xof);
|
||||
});
|
||||
|
||||
test('estInternationale — EUR/USD/GBP/CAD/CHF → true', () {
|
||||
expect(Devise.eur.estInternationale, isTrue);
|
||||
expect(Devise.usd.estInternationale, isTrue);
|
||||
expect(Devise.gbp.estInternationale, isTrue);
|
||||
expect(Devise.cad.estInternationale, isTrue);
|
||||
expect(Devise.chf.estInternationale, isTrue);
|
||||
});
|
||||
|
||||
test('estInternationale — XOF/XAF/GHS/NGN/MAD → false', () {
|
||||
expect(Devise.xof.estInternationale, isFalse);
|
||||
expect(Devise.xaf.estInternationale, isFalse);
|
||||
expect(Devise.ghs.estInternationale, isFalse);
|
||||
expect(Devise.ngn.estInternationale, isFalse);
|
||||
expect(Devise.mad.estInternationale, isFalse);
|
||||
});
|
||||
|
||||
test('Toutes les devises ont code 3 lettres + libellé non vide + zone', () {
|
||||
for (final d in Devise.values) {
|
||||
expect(d.code.length, 3);
|
||||
expect(d.libelle.isNotEmpty, isTrue);
|
||||
expect(d.zone.isNotEmpty, isTrue);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user